mirror of
https://github.com/Eyre-S/Coeur-Morny-Cono.git
synced 2025-01-31 21:12:59 +08:00
[[release 1.3.0*guanggu]]
## 📇 Function & Mechanisms - add daily event statistics, which collects event processed counts and average consumed time - can be found using `/info event` or newly added Morny Daily Report - will reset every 00:00 of report-zone - add `/info tasks` to monitor current coeur task manager status - cha Question Mark Reply support `⸘` - cha bot output supported codeblock language tag (for event_hack output and exception report) - for inline twittet tweet share - add FixTweet output format - removed c.vxtwitter output format (because the upstream default is changed to combined) - support x.com and fixvx.com and fixupx.com for input - add some methods that can get social media status' content - currently support tweeter tweet, and weibo status - weibo with videos is not supported yet - add command `/get <status-url>` can get from a url - add private message listener that can automaticly search supported url from recieved private message and output the status content - add inline query can output status content by using `<supported-url> get` (or `get <supported-url> is also supported`) - weibo with pics and twitter with videos is not supported yet ## 🧯 Bug Fix - fix Telegram User DC matching failure due to cdn domain changed to */cdn-telegram.org - fix InputCommand throws OutOfBounds when input is empty (aka. only `/` in message text) - fix wrong time parsing in OnCallMe-last_dinner ## 🔌 for Trusted/Admin - add Morny Daily Report, will report at 00:00 of report-zone - now will report daily event statistics ## 🔩 for self-hosted/developer - add --report-zone startup param to control which time-zone the Daily Report time should aligned to - default is system default zone - add err handler for MornyCoeur#eventManager(UpdateListener), now error will not output to stderr but Morny's logger. - add util.scheduler and MornyCoeur#tasks for task managering - add MornyCoeur#watchDog - add EventEnv#timeStartup - add atEventPost and executeFilter in EventListener - cha EventEnv#status - add SttpPublic#mornyBasicRequest and make every Morny's HTTP request using it (eveny request now will take Morny UA)
This commit is contained in:
commit
961edd93f3
19
build.gradle
19
build.gradle
@ -83,19 +83,29 @@ dependencies {
|
|||||||
|
|
||||||
implementation group: 'cc.sukazyo', name: 'messiva', version: lib_messiva_v
|
implementation group: 'cc.sukazyo', name: 'messiva', version: lib_messiva_v
|
||||||
implementation group: 'cc.sukazyo', name: 'resource-tools', version: lib_resourcetools_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.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('core'), version: lib_sttp_v
|
||||||
implementation group: 'com.softwaremill.sttp.client3', name: scala('okhttp-backend'), 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.google.code.gson', name: 'gson', version: lib_gson_v
|
||||||
|
implementation group: 'io.circe', name: scala('circe-core'), version: lib_circe_v
|
||||||
|
implementation group: 'io.circe', name: scala('circe-generic'), version: lib_circe_v
|
||||||
|
implementation group: 'io.circe', name: scala('circe-parser'), version: lib_circe_v
|
||||||
|
implementation group: 'org.jsoup', name: 'jsoup', version: '1.16.2'
|
||||||
|
implementation group: 'com.cronutils', name: 'cron-utils', version: lib_cron_utils_v
|
||||||
|
|
||||||
|
// used for disable slf4j
|
||||||
|
// due to the slf4j api have been used in the following libraries:
|
||||||
|
// - cron-utils
|
||||||
|
runtimeOnly group: 'org.slf4j', name: 'slf4j-nop', version: lib_slf4j_v
|
||||||
|
testRuntimeOnly group: 'org.slf4j', name: 'slf4j-nop', version: lib_slf4j_v
|
||||||
|
|
||||||
|
testImplementation group: 'cc.sukazyo', name: 'resource-tools', version: lib_resourcetools_v
|
||||||
testImplementation group: 'org.scalatest', name: scala('scalatest'), version: lib_scalatest_v
|
testImplementation group: 'org.scalatest', name: scala('scalatest'), version: lib_scalatest_v
|
||||||
testImplementation group: 'org.scalatest', name: scala('scalatest-freespec'), 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
|
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'
|
testRuntimeOnly group: 'com.vladsch.flexmark', name: 'flexmark-all', version: '0.64.6'
|
||||||
|
|
||||||
}
|
}
|
||||||
@ -133,6 +143,7 @@ tasks.withType(ScalaCompile).configureEach {
|
|||||||
targetCompatibility proj_java.getMajorVersion()
|
targetCompatibility proj_java.getMajorVersion()
|
||||||
|
|
||||||
scalaCompileOptions.additionalParameters.add "-language:postfixOps"
|
scalaCompileOptions.additionalParameters.add "-language:postfixOps"
|
||||||
|
scalaCompileOptions.additionalParameters.addAll ("-Xmax-inlines", "256")
|
||||||
|
|
||||||
scalaCompileOptions.encoding = proj_file_encoding.name()
|
scalaCompileOptions.encoding = proj_file_encoding.name()
|
||||||
options.encoding = proj_file_encoding.name()
|
options.encoding = proj_file_encoding.name()
|
||||||
|
@ -5,12 +5,12 @@ MORNY_ARCHIVE_NAME = morny-coeur
|
|||||||
MORNY_CODE_STORE = https://github.com/Eyre-S/Coeur-Morny-Cono
|
MORNY_CODE_STORE = https://github.com/Eyre-S/Coeur-Morny-Cono
|
||||||
MORNY_COMMIT_PATH = https://github.com/Eyre-S/Coeur-Morny-Cono/commit/%s
|
MORNY_COMMIT_PATH = https://github.com/Eyre-S/Coeur-Morny-Cono/commit/%s
|
||||||
|
|
||||||
VERSION = 1.2.2-beta2
|
VERSION = 1.3.0
|
||||||
|
|
||||||
USE_DELTA = false
|
USE_DELTA = false
|
||||||
VERSION_DELTA =
|
VERSION_DELTA =
|
||||||
|
|
||||||
CODENAME = xiongan
|
CODENAME = guanggu
|
||||||
|
|
||||||
# dependencies
|
# dependencies
|
||||||
|
|
||||||
@ -19,11 +19,14 @@ lib_scalamodule_xml_v = 2.2.0
|
|||||||
|
|
||||||
lib_messiva_v = 0.2.0
|
lib_messiva_v = 0.2.0
|
||||||
lib_resourcetools_v = 0.2.2
|
lib_resourcetools_v = 0.2.2
|
||||||
|
lib_slf4j_v = 2.0.9
|
||||||
|
|
||||||
lib_javatelegramapi_v = 6.2.0
|
lib_javatelegramapi_v = 6.2.0
|
||||||
|
|
||||||
lib_sttp_v = 3.9.0
|
lib_sttp_v = 3.9.0
|
||||||
lib_okhttp_v = 4.11.0
|
lib_okhttp_v = 4.11.0
|
||||||
lib_gson_v = 2.10.1
|
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
|
lib_scalatest_v = 3.2.17
|
||||||
|
@ -7,9 +7,13 @@ import cc.sukazyo.cono.morny.MornyCoeur.THREAD_SERVER_EXIT
|
|||||||
import cc.sukazyo.cono.morny.bot.api.EventListenerManager
|
import cc.sukazyo.cono.morny.bot.api.EventListenerManager
|
||||||
import cc.sukazyo.cono.morny.bot.event.{MornyEventListeners, MornyOnInlineQuery, MornyOnTelegramCommand, MornyOnUpdateTimestampOffsetLock}
|
import cc.sukazyo.cono.morny.bot.event.{MornyEventListeners, MornyOnInlineQuery, MornyOnTelegramCommand, MornyOnUpdateTimestampOffsetLock}
|
||||||
import cc.sukazyo.cono.morny.bot.query.MornyQueries
|
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.TelegramBot
|
||||||
import com.pengrad.telegrambot.request.GetMe
|
import com.pengrad.telegrambot.request.GetMe
|
||||||
|
|
||||||
|
import scala.annotation.unused
|
||||||
import scala.util.boundary
|
import scala.util.boundary
|
||||||
import scala.util.boundary.break
|
import scala.util.boundary.break
|
||||||
|
|
||||||
@ -53,7 +57,7 @@ class MornyCoeur (using val config: MornyConfig) {
|
|||||||
*
|
*
|
||||||
* in milliseconds.
|
* in milliseconds.
|
||||||
*/
|
*/
|
||||||
val coeurStartTimestamp: Long = System.currentTimeMillis
|
val coeurStartTimestamp: EpochMillis = System.currentTimeMillis
|
||||||
|
|
||||||
/** [[TelegramBot]] account of this Morny */
|
/** [[TelegramBot]] account of this Morny */
|
||||||
val account: TelegramBot = __loginResult.account
|
val account: TelegramBot = __loginResult.account
|
||||||
@ -62,6 +66,8 @@ class MornyCoeur (using val config: MornyConfig) {
|
|||||||
/** [[account]]'s telegram user id */
|
/** [[account]]'s telegram user id */
|
||||||
val userid: Long = __loginResult.userid
|
val userid: Long = __loginResult.userid
|
||||||
|
|
||||||
|
/** Morny's task [[Scheduler]] */
|
||||||
|
val tasks: Scheduler = Scheduler()
|
||||||
/** current Morny's [[MornyTrusted]] instance */
|
/** current Morny's [[MornyTrusted]] instance */
|
||||||
val trusted: MornyTrusted = MornyTrusted()
|
val trusted: MornyTrusted = MornyTrusted()
|
||||||
|
|
||||||
@ -76,12 +82,67 @@ class MornyCoeur (using val config: MornyConfig) {
|
|||||||
eventManager register MornyOnInlineQuery(using queries)
|
eventManager register MornyOnInlineQuery(using queries)
|
||||||
//noinspection ScalaUnusedSymbol
|
//noinspection ScalaUnusedSymbol
|
||||||
val events: MornyEventListeners = MornyEventListeners(using eventManager)
|
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
|
///>>> BLOCK START instance configure & startup stage 2
|
||||||
|
|
||||||
daemons.start()
|
daemons.start()
|
||||||
logger info "start telegram event listening"
|
logger info "start telegram event listening"
|
||||||
account setUpdatesListener eventManager
|
import com.pengrad.telegrambot.TelegramException
|
||||||
|
account.setUpdatesListener(eventManager, (e: TelegramException) => {
|
||||||
|
|
||||||
|
// This function intended to catch exceptions on update
|
||||||
|
// fetching controlled by Telegram API Client. So that
|
||||||
|
// it won't be directly printed to STDOUT without Morny's
|
||||||
|
// logger. And it can be reported when needed.
|
||||||
|
// TelegramException can either contains a caused that infers
|
||||||
|
// a lower level client exception (network err or others);
|
||||||
|
// nor contains a response that means API request failed.
|
||||||
|
|
||||||
|
if (e.response != null) {
|
||||||
|
import com.google.gson.GsonBuilder
|
||||||
|
logger error
|
||||||
|
s"""Failed get updates: ${e.getMessage}
|
||||||
|
| server responses:
|
||||||
|
|${GsonBuilder().setPrettyPrinting().create.toJson(e.response) indent 4}
|
||||||
|
|""".stripMargin
|
||||||
|
this.daemons.reporter.exception(e, "Failed get updates.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.getCause != null) {
|
||||||
|
import java.net.{SocketException, SocketTimeoutException}
|
||||||
|
import javax.net.ssl.SSLHandshakeException
|
||||||
|
val caused = e.getCause
|
||||||
|
caused match
|
||||||
|
case e_timeout: (SSLHandshakeException|SocketException|SocketTimeoutException) =>
|
||||||
|
import cc.sukazyo.messiva.log.Message
|
||||||
|
|
||||||
|
import scala.collection.mutable
|
||||||
|
val log = mutable.ArrayBuffer(s"Failed get updates: Network Error")
|
||||||
|
var current: Throwable = e_timeout
|
||||||
|
log += s" due to: ${current.getClass.getSimpleName}: ${current.getMessage}"
|
||||||
|
while (current.getCause != null) {
|
||||||
|
current = current.getCause
|
||||||
|
log += s" caused by: ${current.getClass.getSimpleName}: ${current.getMessage}"
|
||||||
|
}
|
||||||
|
logger error Message(log mkString "\n")
|
||||||
|
case e_other =>
|
||||||
|
logger error
|
||||||
|
s"""Failed get updates:
|
||||||
|
|${exceptionLog(e_other) indent 3}""".stripMargin
|
||||||
|
this.daemons.reporter.exception(e_other, "Failed get updates.")
|
||||||
|
}
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
if config.commandLoginRefresh then
|
if config.commandLoginRefresh then
|
||||||
logger info "resetting telegram command list"
|
logger info "resetting telegram command list"
|
||||||
commands.automaticTGListUpdate()
|
commands.automaticTGListUpdate()
|
||||||
@ -101,6 +162,8 @@ class MornyCoeur (using val config: MornyConfig) {
|
|||||||
account.shutdown()
|
account.shutdown()
|
||||||
logger info "stopped bot account"
|
logger info "stopped bot account"
|
||||||
daemons.stop()
|
daemons.stop()
|
||||||
|
tasks.waitForStop()
|
||||||
|
logger info s"morny tasks stopped: remains ${tasks.amount} tasks not be executed"
|
||||||
if config.commandLogoutClear then
|
if config.commandLogoutClear then
|
||||||
commands.automaticTGListRemove()
|
commands.automaticTGListRemove()
|
||||||
logger info "done exit cleanup"
|
logger info "done exit cleanup"
|
||||||
|
@ -6,6 +6,7 @@ import java.lang.annotation.*;
|
|||||||
import java.time.ZoneOffset;
|
import java.time.ZoneOffset;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
import java.util.TimeZone;
|
||||||
|
|
||||||
public class MornyConfig {
|
public class MornyConfig {
|
||||||
|
|
||||||
@ -109,6 +110,18 @@ public class MornyConfig {
|
|||||||
*/
|
*/
|
||||||
public final long reportToChat;
|
public final long reportToChat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 控制 Morny Coeur 系统的报告的基准时间.
|
||||||
|
* <p>
|
||||||
|
* 仅会用于 {@link cc.sukazyo.cono.morny.daemon.MornyReport} 内的时间敏感的报告,
|
||||||
|
* 不会用于 {@code /info} 命令等位置。
|
||||||
|
* <p>
|
||||||
|
* 默认使用 {@link TimeZone#getDefault()}.
|
||||||
|
*
|
||||||
|
* @since 1.3.0
|
||||||
|
*/
|
||||||
|
@Nonnull public final TimeZone reportZone;
|
||||||
|
|
||||||
/* ======================================= *
|
/* ======================================= *
|
||||||
* function: dinner query tool *
|
* function: dinner query tool *
|
||||||
* ======================================= */
|
* ======================================= */
|
||||||
@ -144,6 +157,7 @@ public class MornyConfig {
|
|||||||
this.dinnerTrustedReaders = prototype.dinnerTrustedReaders;
|
this.dinnerTrustedReaders = prototype.dinnerTrustedReaders;
|
||||||
this.dinnerChatId = prototype.dinnerChatId;
|
this.dinnerChatId = prototype.dinnerChatId;
|
||||||
this.reportToChat = prototype.reportToChat;
|
this.reportToChat = prototype.reportToChat;
|
||||||
|
this.reportZone = prototype.reportZone;
|
||||||
this.medicationNotifyToChat = prototype.medicationNotifyToChat;
|
this.medicationNotifyToChat = prototype.medicationNotifyToChat;
|
||||||
this.medicationTimerUseTimezone = prototype.medicationTimerUseTimezone;
|
this.medicationTimerUseTimezone = prototype.medicationTimerUseTimezone;
|
||||||
prototype.medicationNotifyAt.forEach(i -> { if (i < 0 || i > 23) throw new CheckFailure.UnavailableTimeInMedicationNotifyAt(); });
|
prototype.medicationNotifyAt.forEach(i -> { if (i < 0 || i > 23) throw new CheckFailure.UnavailableTimeInMedicationNotifyAt(); });
|
||||||
@ -173,6 +187,7 @@ public class MornyConfig {
|
|||||||
@Nonnull public final Set<Long> dinnerTrustedReaders = new HashSet<>();
|
@Nonnull public final Set<Long> dinnerTrustedReaders = new HashSet<>();
|
||||||
public long dinnerChatId = -1L;
|
public long dinnerChatId = -1L;
|
||||||
public long reportToChat = -1L;
|
public long reportToChat = -1L;
|
||||||
|
@Nonnull public TimeZone reportZone = TimeZone.getDefault();
|
||||||
public long medicationNotifyToChat = -1L;
|
public long medicationNotifyToChat = -1L;
|
||||||
@Nonnull public ZoneOffset medicationTimerUseTimezone = ZoneOffset.UTC;
|
@Nonnull public ZoneOffset medicationTimerUseTimezone = ZoneOffset.UTC;
|
||||||
@Nonnull public final Set<Integer> medicationNotifyAt = new HashSet<>();
|
@Nonnull public final Set<Integer> medicationNotifyAt = new HashSet<>();
|
||||||
|
@ -51,6 +51,7 @@ object ServerMain {
|
|||||||
case "--master" | "-mm" => i+=1 ; config.trustedMaster = args(i)toLong
|
case "--master" | "-mm" => i+=1 ; config.trustedMaster = args(i)toLong
|
||||||
case "--trusted-chat" | "-trs" => i+=1 ; config.trustedChat = 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-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 "--trusted-reader-dinner" | "-trsd" => i+=1 ; config.dinnerTrustedReaders add (args(i)toLong)
|
||||||
case "--dinner-chat" | "-chd" => i+=1 ; config.dinnerChatId = args(i)toLong
|
case "--dinner-chat" | "-chd" => i+=1 ; config.dinnerChatId = args(i)toLong
|
||||||
|
@ -1,8 +1,11 @@
|
|||||||
package cc.sukazyo.cono.morny.bot.api
|
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 com.pengrad.telegrambot.model.Update
|
||||||
|
|
||||||
import scala.collection.mutable
|
import scala.collection.mutable
|
||||||
|
import scala.reflect.{classTag, ClassTag}
|
||||||
|
|
||||||
class EventEnv (
|
class EventEnv (
|
||||||
|
|
||||||
@ -10,14 +13,34 @@ class EventEnv (
|
|||||||
|
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private var _isOk: Int = 0
|
trait StateSource (val from: StackTraceElement)
|
||||||
private val variables: mutable.HashMap[Class[?], Any] = mutable.HashMap.empty
|
enum State:
|
||||||
|
case OK (_from: StackTraceElement) extends State with StateSource(_from)
|
||||||
|
case CANCELED (_from: StackTraceElement) extends State with StateSource(_from)
|
||||||
|
|
||||||
def isEventOk: Boolean = _isOk > 0
|
private val _status: mutable.ListBuffer[State] = mutable.ListBuffer.empty
|
||||||
|
private val variables: mutable.HashMap[Class[?], Any] = mutable.HashMap.empty
|
||||||
|
val timeStartup: EpochMillis = System.currentTimeMillis
|
||||||
|
|
||||||
|
def isEventOk: Boolean = _status.lastOption match
|
||||||
|
case Some(x) if x == State.OK => true
|
||||||
|
case _ => false
|
||||||
|
|
||||||
//noinspection UnitMethodIsParameterless
|
//noinspection UnitMethodIsParameterless
|
||||||
def setEventOk: Unit =
|
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 =
|
def provide (i: Any): Unit =
|
||||||
variables += (i.getClass -> i)
|
variables += (i.getClass -> i)
|
||||||
@ -28,6 +51,11 @@ class EventEnv (
|
|||||||
case None => ConsumeResult(false)
|
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) {
|
class ConsumeResult (success: Boolean) {
|
||||||
def onfail (processor: => Unit): Unit = {
|
def onfail (processor: => Unit): Unit = {
|
||||||
if !success then processor
|
if !success then processor
|
||||||
|
@ -2,6 +2,32 @@ package cc.sukazyo.cono.morny.bot.api
|
|||||||
|
|
||||||
trait EventListener () {
|
trait EventListener () {
|
||||||
|
|
||||||
|
/** Determine if this event listener should be processed.
|
||||||
|
*
|
||||||
|
* Default implementation is it only be [[true]] when the event
|
||||||
|
* is not ok yet (when [[EventEnv.isEventOk]] is false).
|
||||||
|
*
|
||||||
|
* Notice that: You should not override this method to filter some
|
||||||
|
* affair level conditions (such as if this update contains a text
|
||||||
|
* message), you should write them to the listener function! This
|
||||||
|
* method is just for event low-level controls.
|
||||||
|
*
|
||||||
|
* @param env The [[EventEnv event variable]].
|
||||||
|
* @return [[true]] if this event listener should run; [[false]]
|
||||||
|
* if it should not run.
|
||||||
|
*/
|
||||||
|
def executeFilter (using env: EventEnv): Boolean =
|
||||||
|
if env.state == null then true else false
|
||||||
|
|
||||||
|
/** Run at all event listeners' listen methods done.
|
||||||
|
*
|
||||||
|
* Listen methods is the methods defined in [[EventListener this]]
|
||||||
|
* trait starts with `on`.
|
||||||
|
*
|
||||||
|
* This method will always run no matter the result of [[executeFilter]]
|
||||||
|
*/
|
||||||
|
def atEventPost (using EventEnv): Unit = {}
|
||||||
|
|
||||||
def onMessage (using EventEnv): Unit = {}
|
def onMessage (using EventEnv): Unit = {}
|
||||||
def onEditedMessage (using EventEnv): Unit = {}
|
def onEditedMessage (using EventEnv): Unit = {}
|
||||||
def onChannelPost (using EventEnv): Unit = {}
|
def onChannelPost (using EventEnv): Unit = {}
|
||||||
|
@ -9,7 +9,6 @@ import com.pengrad.telegrambot.UpdatesListener
|
|||||||
|
|
||||||
import scala.collection.mutable
|
import scala.collection.mutable
|
||||||
import scala.language.postfixOps
|
import scala.language.postfixOps
|
||||||
import scala.util.boundary
|
|
||||||
|
|
||||||
/** Contains a [[mutable.Queue]] of [[EventListener]], and delivery telegram [[Update]].
|
/** Contains a [[mutable.Queue]] of [[EventListener]], and delivery telegram [[Update]].
|
||||||
*
|
*
|
||||||
@ -30,53 +29,66 @@ class EventListenerManager (using coeur: MornyCoeur) extends UpdatesListener {
|
|||||||
this setName s"upd-${update.updateId()}-$t"
|
this setName s"upd-${update.updateId()}-$t"
|
||||||
|
|
||||||
override def run (): Unit = {
|
override def run (): Unit = {
|
||||||
|
|
||||||
given env: EventEnv = EventEnv(update)
|
given env: EventEnv = EventEnv(update)
|
||||||
boundary { for (i <- listeners) {
|
|
||||||
try {
|
for (i <- listeners)
|
||||||
updateThreadName("message")
|
if (i.executeFilter)
|
||||||
if update.message ne null then i.onMessage
|
runEventListener(i)
|
||||||
updateThreadName("edited-message")
|
for (i <- listeners)
|
||||||
if update.editedMessage ne null then i.onEditedMessage
|
runEventPost(i)
|
||||||
updateThreadName("channel-post")
|
|
||||||
if update.channelPost ne null then i.onChannelPost
|
}
|
||||||
updateThreadName("edited-channel-post")
|
|
||||||
if update.editedChannelPost ne null then i.onEditedChannelPost
|
private def runEventPost (i: EventListener)(using EventEnv): Unit = {
|
||||||
updateThreadName("inline-query")
|
updateThreadName("#post")
|
||||||
if update.inlineQuery ne null then i.onInlineQuery
|
i.atEventPost
|
||||||
updateThreadName("chosen-inline-result")
|
}
|
||||||
if update.chosenInlineResult ne null then i.onChosenInlineResult
|
|
||||||
updateThreadName("callback-query")
|
private def runEventListener (i: EventListener)(using EventEnv): Unit = {
|
||||||
if update.callbackQuery ne null then i.onCallbackQuery
|
try {
|
||||||
updateThreadName("shipping-query")
|
updateThreadName("message")
|
||||||
if update.shippingQuery ne null then i.onShippingQuery
|
if update.message ne null then i.onMessage
|
||||||
updateThreadName("pre-checkout-query")
|
updateThreadName("edited-message")
|
||||||
if update.preCheckoutQuery ne null then i.onPreCheckoutQuery
|
if update.editedMessage ne null then i.onEditedMessage
|
||||||
updateThreadName("poll")
|
updateThreadName("channel-post")
|
||||||
if update.poll ne null then i.onPoll
|
if update.channelPost ne null then i.onChannelPost
|
||||||
updateThreadName("poll-answer")
|
updateThreadName("edited-channel-post")
|
||||||
if update.pollAnswer ne null then i.onPollAnswer
|
if update.editedChannelPost ne null then i.onEditedChannelPost
|
||||||
updateThreadName("my-chat-member")
|
updateThreadName("inline-query")
|
||||||
if update.myChatMember ne null then i.onMyChatMemberUpdated
|
if update.inlineQuery ne null then i.onInlineQuery
|
||||||
updateThreadName("chat-member")
|
updateThreadName("chosen-inline-result")
|
||||||
if update.chatMember ne null then i.onChatMemberUpdated
|
if update.chosenInlineResult ne null then i.onChosenInlineResult
|
||||||
updateThreadName("chat-join-request")
|
updateThreadName("callback-query")
|
||||||
if update.chatJoinRequest ne null then i.onChatJoinRequest
|
if update.callbackQuery ne null then i.onCallbackQuery
|
||||||
} catch case e => {
|
updateThreadName("shipping-query")
|
||||||
val errorMessage = StringBuilder()
|
if update.shippingQuery ne null then i.onShippingQuery
|
||||||
errorMessage ++= "Event throws unexpected exception:\n"
|
updateThreadName("pre-checkout-query")
|
||||||
errorMessage ++= (exceptionLog(e) indent 4)
|
if update.preCheckoutQuery ne null then i.onPreCheckoutQuery
|
||||||
e match
|
updateThreadName("poll")
|
||||||
case actionFailed: EventRuntimeException.ActionFailed =>
|
if update.poll ne null then i.onPoll
|
||||||
errorMessage ++= "\ntg-api action: response track: "
|
updateThreadName("poll-answer")
|
||||||
errorMessage ++= (GsonBuilder().setPrettyPrinting().create().toJson(
|
if update.pollAnswer ne null then i.onPollAnswer
|
||||||
actionFailed.response
|
updateThreadName("my-chat-member")
|
||||||
) indent 4) ++= "\n"
|
if update.myChatMember ne null then i.onMyChatMemberUpdated
|
||||||
case _ =>
|
updateThreadName("chat-member")
|
||||||
logger error errorMessage.toString
|
if update.chatMember ne null then i.onChatMemberUpdated
|
||||||
coeur.daemons.reporter.exception(e, "on event running")
|
updateThreadName("chat-join-request")
|
||||||
}
|
if update.chatJoinRequest ne null then i.onChatJoinRequest
|
||||||
if env.isEventOk then boundary.break()
|
} 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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -196,6 +196,7 @@ class Encryptor (using coeur: MornyCoeur) extends ITelegramCommand {
|
|||||||
import cc.sukazyo.cono.morny.util.tgapi.formatting.TelegramParseEscape.escapeHtml as h
|
import cc.sukazyo.cono.morny.util.tgapi.formatting.TelegramParseEscape.escapeHtml as h
|
||||||
coeur.account exec SendMessage(
|
coeur.account exec SendMessage(
|
||||||
event.message.chat.id,
|
event.message.chat.id,
|
||||||
|
// language=html
|
||||||
s"<pre><code>${h(_text.text)}</code></pre>"
|
s"<pre><code>${h(_text.text)}</code></pre>"
|
||||||
).parseMode(ParseMode HTML).replyToMessageId(event.message.messageId)
|
).parseMode(ParseMode HTML).replyToMessageId(event.message.messageId)
|
||||||
|
|
||||||
|
@ -0,0 +1,32 @@
|
|||||||
|
package cc.sukazyo.cono.morny.bot.command
|
||||||
|
import cc.sukazyo.cono.morny.data.TelegramStickers
|
||||||
|
import cc.sukazyo.cono.morny.util.tgapi.InputCommand
|
||||||
|
import cc.sukazyo.cono.morny.MornyCoeur
|
||||||
|
import cc.sukazyo.cono.morny.bot.event.OnGetSocial
|
||||||
|
import cc.sukazyo.cono.morny.util.tgapi.TelegramExtensions.Bot.exec
|
||||||
|
import com.pengrad.telegrambot.model.Update
|
||||||
|
import com.pengrad.telegrambot.request.SendSticker
|
||||||
|
|
||||||
|
class GetSocial (using coeur: MornyCoeur) extends ITelegramCommand {
|
||||||
|
|
||||||
|
override val name: String = "get"
|
||||||
|
override val aliases: Array[ICommandAlias] | Null = null
|
||||||
|
override val paramRule: String = "<tweet-url|weibo-status-url>"
|
||||||
|
override val description: String = "从社交媒体分享链接获取其内容"
|
||||||
|
|
||||||
|
override def execute (using command: InputCommand, event: Update): Unit = {
|
||||||
|
|
||||||
|
def do404 (): Unit =
|
||||||
|
coeur.account exec SendSticker(
|
||||||
|
event.message.chat.id,
|
||||||
|
TelegramStickers.ID_404
|
||||||
|
).replyToMessageId(event.message.messageId())
|
||||||
|
|
||||||
|
if command.args.length < 1 then { do404(); return }
|
||||||
|
|
||||||
|
if !OnGetSocial.tryFetchSocial(Right(command.args(0)))(using event.message.chat.id, event.message.messageId) then
|
||||||
|
do404()
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -1,7 +1,7 @@
|
|||||||
package cc.sukazyo.cono.morny.bot.command
|
package cc.sukazyo.cono.morny.bot.command
|
||||||
|
|
||||||
import cc.sukazyo.cono.morny.MornyCoeur
|
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.InputCommand
|
||||||
import cc.sukazyo.cono.morny.util.tgapi.TelegramExtensions.Bot.exec
|
import cc.sukazyo.cono.morny.util.tgapi.TelegramExtensions.Bot.exec
|
||||||
import com.pengrad.telegrambot.model.Update
|
import com.pengrad.telegrambot.model.Update
|
||||||
|
@ -43,11 +43,13 @@ class MornyCommands (using coeur: MornyCoeur) {
|
|||||||
$IP186Query.IP,
|
$IP186Query.IP,
|
||||||
$IP186Query.Whois,
|
$IP186Query.Whois,
|
||||||
Encryptor(),
|
Encryptor(),
|
||||||
|
MornyOldJrrp(),
|
||||||
|
GetSocial(),
|
||||||
|
|
||||||
$MornyManagers.SaveData,
|
$MornyManagers.SaveData,
|
||||||
$MornyInformation,
|
$MornyInformation,
|
||||||
$MornyInformationOlds.Version,
|
$MornyInformationOlds.Version,
|
||||||
$MornyInformationOlds.Runtime,
|
$MornyInformationOlds.Runtime,
|
||||||
MornyOldJrrp(),
|
|
||||||
$MornyManagers.Exit,
|
$MornyManagers.Exit,
|
||||||
|
|
||||||
Testing(),
|
Testing(),
|
||||||
|
@ -22,11 +22,13 @@ class MornyInformation (using coeur: MornyCoeur) extends ITelegramCommand {
|
|||||||
val RUNTIME = "runtime"
|
val RUNTIME = "runtime"
|
||||||
val VERSION = "version"
|
val VERSION = "version"
|
||||||
val VERSION_2 = "v"
|
val VERSION_2 = "v"
|
||||||
|
val TASKS = "tasks"
|
||||||
|
val EVENTS = "event"
|
||||||
}
|
}
|
||||||
|
|
||||||
override val name: String = "info"
|
override val name: String = "info"
|
||||||
override val aliases: Array[ICommandAlias]|Null = null
|
override val aliases: Array[ICommandAlias]|Null = null
|
||||||
override val paramRule: String = "[(version|runtime|stickers[.IDs])]"
|
override val paramRule: String = "[(version|runtime|stickers[.IDs]|tasks|event)]"
|
||||||
override val description: String = "输出当前 Morny 的各种信息"
|
override val description: String = "输出当前 Morny 的各种信息"
|
||||||
|
|
||||||
override def execute (using command: InputCommand, event: Update): Unit = {
|
override def execute (using command: InputCommand, event: Update): Unit = {
|
||||||
@ -42,6 +44,8 @@ class MornyInformation (using coeur: MornyCoeur) extends ITelegramCommand {
|
|||||||
case s if s startsWith Subs.STICKERS => echoStickers
|
case s if s startsWith Subs.STICKERS => echoStickers
|
||||||
case Subs.RUNTIME => echoRuntime
|
case Subs.RUNTIME => echoRuntime
|
||||||
case Subs.VERSION | Subs.VERSION_2 => echoVersion
|
case Subs.VERSION | Subs.VERSION_2 => echoVersion
|
||||||
|
case Subs.TASKS => echoTasksStatus
|
||||||
|
case Subs.EVENTS => echoEventStatistics
|
||||||
case _ => echo404
|
case _ => echo404
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -144,6 +148,29 @@ class MornyInformation (using coeur: MornyCoeur) extends ITelegramCommand {
|
|||||||
).parseMode(ParseMode HTML).replyToMessageId(event.message.messageId)
|
).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"""<b>Coeur Task Scheduler:</b>
|
||||||
|
| - <i>scheduled tasks</i>: <code>${coeur.tasks.amount}</code>
|
||||||
|
| - <i>scheduler status</i>: <code>${coeur.tasks.state}</code>
|
||||||
|
| - <i>current runner status</i>: <code>${coeur.tasks.runnerState}</code>
|
||||||
|
|""".stripMargin
|
||||||
|
).parseMode(ParseMode.HTML).replyToMessageId(update.message.messageId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private def echoEventStatistics (using update: Update): Unit = {
|
||||||
|
coeur.account exec SendMessage(
|
||||||
|
update.message.chat.id,
|
||||||
|
// language=html
|
||||||
|
s"""<b>Event Statistics :</b>
|
||||||
|
|in today
|
||||||
|
|${coeur.daemons.reporter.EventStatistics.eventStatisticsHTML}""".stripMargin
|
||||||
|
).parseMode(ParseMode.HTML).replyToMessageId(update.message.messageId)
|
||||||
|
}
|
||||||
|
|
||||||
private def echo404 (using event: Update): Unit =
|
private def echo404 (using event: Update): Unit =
|
||||||
coeur.account exec new SendSticker(
|
coeur.account exec new SendSticker(
|
||||||
event.message.chat.id,
|
event.message.chat.id,
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
package cc.sukazyo.cono.morny.bot.command
|
package cc.sukazyo.cono.morny.bot.command
|
||||||
|
|
||||||
import cc.sukazyo.cono.morny.MornyCoeur
|
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.formatting.TelegramParseEscape.escapeHtml as h
|
||||||
import cc.sukazyo.cono.morny.util.tgapi.InputCommand
|
import cc.sukazyo.cono.morny.util.tgapi.InputCommand
|
||||||
import cc.sukazyo.cono.morny.util.tgapi.TelegramExtensions.Bot.exec
|
import cc.sukazyo.cono.morny.util.tgapi.TelegramExtensions.Bot.exec
|
||||||
|
@ -17,6 +17,7 @@ class MornyEventListeners (using manager: EventListenerManager) (using coeur: Mo
|
|||||||
OnUserSlashAction(),
|
OnUserSlashAction(),
|
||||||
OnCallMe(),
|
OnCallMe(),
|
||||||
OnCallMsgSend(),
|
OnCallMsgSend(),
|
||||||
|
OnGetSocial(),
|
||||||
OnMedicationNotifyApply(),
|
OnMedicationNotifyApply(),
|
||||||
OnEventHackHandle()
|
OnEventHackHandle()
|
||||||
)
|
)
|
||||||
|
@ -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.bot.api.{EventEnv, EventListener}
|
||||||
import cc.sukazyo.cono.morny.MornyCoeur
|
import cc.sukazyo.cono.morny.MornyCoeur
|
||||||
import com.pengrad.telegrambot.model.Update
|
|
||||||
|
|
||||||
class MornyOnUpdateTimestampOffsetLock (using coeur: MornyCoeur) extends EventListener {
|
class MornyOnUpdateTimestampOffsetLock (using coeur: MornyCoeur) extends EventListener {
|
||||||
|
|
||||||
private def checkOutdated (timestamp: Int)(using event: EventEnv): Unit =
|
private def checkOutdated (timestamp: Int)(using event: EventEnv): Unit =
|
||||||
if coeur.config.eventIgnoreOutdated && (timestamp < (coeur.coeurStartTimestamp/1000)) then
|
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 onMessage (using event: EventEnv): Unit = checkOutdated(event.update.message.date)
|
||||||
override def onEditedMessage (using event: EventEnv): Unit = checkOutdated(event.update.editedMessage.date)
|
override def onEditedMessage (using event: EventEnv): Unit = checkOutdated(event.update.editedMessage.date)
|
||||||
|
@ -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.data.TelegramStickers
|
||||||
import cc.sukazyo.cono.morny.util.tgapi.formatting.TelegramFormatter.*
|
import cc.sukazyo.cono.morny.util.tgapi.formatting.TelegramFormatter.*
|
||||||
import cc.sukazyo.cono.morny.util.tgapi.TelegramExtensions.Bot.exec
|
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.model.request.ParseMode
|
||||||
import com.pengrad.telegrambot.request.{ForwardMessage, GetChat, SendMessage, SendSticker}
|
import com.pengrad.telegrambot.request.{ForwardMessage, GetChat, SendMessage, SendSticker}
|
||||||
|
|
||||||
@ -73,13 +73,14 @@ class OnCallMe (using coeur: MornyCoeur) extends EventListener {
|
|||||||
lastDinnerData.forwardFromMessageId
|
lastDinnerData.forwardFromMessageId
|
||||||
)
|
)
|
||||||
import cc.sukazyo.cono.morny.util.CommonFormat.{formatDate, formatDuration}
|
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
|
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(
|
coeur.account exec SendMessage(
|
||||||
req.from.id,
|
req.from.id,
|
||||||
"<i>on</i> <code>%s [UTC+8]</code>\n- <code>%s</code> <i>before</i>".formatted(
|
"<i>on</i> <code>%s [UTC+8]</code>\n- <code>%s</code> <i>before</i>".formatted(
|
||||||
h(formatDate(lastDinner_dateMillis, 8)),
|
h(formatDate(lastDinner_dateMillis, 8)),
|
||||||
h(formatDuration(lastDinner_dateMillis))
|
h(formatDuration(System.currentTimeMillis - lastDinner_dateMillis))
|
||||||
)
|
)
|
||||||
).parseMode(ParseMode HTML).replyToMessageId(sendResp.message.messageId)
|
).parseMode(ParseMode HTML).replyToMessageId(sendResp.message.messageId)
|
||||||
isAllowed = true
|
isAllowed = true
|
||||||
|
116
src/main/scala/cc/sukazyo/cono/morny/bot/event/OnGetSocial.scala
Normal file
116
src/main/scala/cc/sukazyo/cono/morny/bot/event/OnGetSocial.scala
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
package cc.sukazyo.cono.morny.bot.event
|
||||||
|
|
||||||
|
import cc.sukazyo.cono.morny.MornyCoeur
|
||||||
|
import cc.sukazyo.cono.morny.bot.api.{EventEnv, EventListener}
|
||||||
|
import cc.sukazyo.cono.morny.bot.event.OnGetSocial.tryFetchSocial
|
||||||
|
import cc.sukazyo.cono.morny.data.TelegramStickers
|
||||||
|
import cc.sukazyo.cono.morny.extra.{twitter, weibo}
|
||||||
|
import cc.sukazyo.cono.morny.util.tgapi.TelegramExtensions.Bot.exec
|
||||||
|
import cc.sukazyo.cono.morny.Log.{exceptionLog, logger}
|
||||||
|
import cc.sukazyo.cono.morny.data.social.{SocialTwitterParser, SocialWeiboParser}
|
||||||
|
import cc.sukazyo.cono.morny.util.tgapi.TelegramExtensions.Message.entitiesSafe
|
||||||
|
import com.pengrad.telegrambot.model.Chat
|
||||||
|
import com.pengrad.telegrambot.model.request.ParseMode
|
||||||
|
import com.pengrad.telegrambot.request.{SendMessage, SendSticker}
|
||||||
|
|
||||||
|
class OnGetSocial (using coeur: MornyCoeur) extends EventListener {
|
||||||
|
|
||||||
|
override def onMessage (using event: EventEnv): Unit = {
|
||||||
|
import event.update.message as messageEvent
|
||||||
|
|
||||||
|
if messageEvent.chat.`type` != Chat.Type.Private then return;
|
||||||
|
if messageEvent.text == null then return;
|
||||||
|
|
||||||
|
if tryFetchSocial(
|
||||||
|
Left((
|
||||||
|
messageEvent.text :: messageEvent.entitiesSafe.map(f => f.url).filterNot(f => f == null)
|
||||||
|
).mkString(" "))
|
||||||
|
)(using messageEvent.chat.id, messageEvent.messageId) then
|
||||||
|
event.setEventOk
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
object OnGetSocial {
|
||||||
|
|
||||||
|
/** Try fetch from url from input and output fetched social content.
|
||||||
|
*
|
||||||
|
* @param text input text, receive either a texts contains some URLs that should
|
||||||
|
* pass through [[Left]], or a exactly URL that should pass through
|
||||||
|
* [[Right]].
|
||||||
|
* @param replyChat chat that should be output to.
|
||||||
|
* @param replyToMessage message that should be reply to.
|
||||||
|
* @param coeur [[MornyCoeur]] instance for executing Telegram function.
|
||||||
|
* @return [[true]] if fetched social content and sent something out.
|
||||||
|
*/
|
||||||
|
def tryFetchSocial (text: Either[String, String])(using replyChat: Long, replyToMessage: Int)(using coeur: MornyCoeur): Boolean = {
|
||||||
|
|
||||||
|
var succeed = 0
|
||||||
|
|
||||||
|
{
|
||||||
|
text match
|
||||||
|
case Left(texts) =>
|
||||||
|
twitter.guessTweetUrl(texts.trim)
|
||||||
|
case Right(url) =>
|
||||||
|
twitter.parseTweetUrl(url.trim).toList
|
||||||
|
}.map(f => {
|
||||||
|
succeed += 1
|
||||||
|
tryFetchSocialOfTweet(f)
|
||||||
|
})
|
||||||
|
|
||||||
|
{
|
||||||
|
text match
|
||||||
|
case Left(texts) =>
|
||||||
|
weibo.guessWeiboStatusUrl(texts.trim)
|
||||||
|
case Right(url) =>
|
||||||
|
weibo.parseWeiboStatusUrl(url.trim).toList
|
||||||
|
}.map(f => {
|
||||||
|
succeed += 1
|
||||||
|
tryFetchSocialOfWeibo(f)
|
||||||
|
})
|
||||||
|
succeed > 0
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
def tryFetchSocialOfTweet (url: twitter.TweetUrlInformation)(using replyChat: Long, replyToMessage: Int)(using coeur: MornyCoeur) =
|
||||||
|
import io.circe.{DecodingFailure, ParsingFailure}
|
||||||
|
import sttp.client3.SttpClientException
|
||||||
|
import twitter.FXApi
|
||||||
|
try {
|
||||||
|
val api = FXApi.Fetch.status(Some(url.screenName), url.statusId)
|
||||||
|
SocialTwitterParser.parseFXTweet(api).outputToTelegram
|
||||||
|
} catch case e: (SttpClientException | ParsingFailure | DecodingFailure) =>
|
||||||
|
coeur.account exec SendSticker(
|
||||||
|
replyChat,
|
||||||
|
TelegramStickers.ID_NETWORK_ERR
|
||||||
|
).replyToMessageId(replyToMessage)
|
||||||
|
logger error
|
||||||
|
"Error on requesting FixTweet API\n" + exceptionLog(e)
|
||||||
|
coeur.daemons.reporter.exception(e, "Error on requesting FixTweet API")
|
||||||
|
|
||||||
|
def tryFetchSocialOfWeibo (url: weibo.StatusUrlInfo)(using replyChat: Long, replyToMessage: Int)(using coeur: MornyCoeur) =
|
||||||
|
import io.circe.{DecodingFailure, ParsingFailure}
|
||||||
|
import sttp.client3.{HttpError, SttpClientException}
|
||||||
|
import weibo.MApi
|
||||||
|
try {
|
||||||
|
val api = MApi.Fetch.statuses_show(url.id)
|
||||||
|
SocialWeiboParser.parseMStatus(api).outputToTelegram
|
||||||
|
} catch
|
||||||
|
case e: HttpError[?] =>
|
||||||
|
coeur.account exec SendMessage(
|
||||||
|
replyChat,
|
||||||
|
// language=html
|
||||||
|
s"""Weibo Request Error <code>${e.statusCode}</code>
|
||||||
|
|<pre><code>${e.body}</code></pre>""".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")
|
||||||
|
|
||||||
|
}
|
@ -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.bot.api.{EventEnv, EventListener}
|
||||||
import cc.sukazyo.cono.morny.MornyCoeur
|
import cc.sukazyo.cono.morny.MornyCoeur
|
||||||
import cc.sukazyo.cono.morny.daemon.{MedicationTimer, MornyDaemons}
|
import com.pengrad.telegrambot.model.Message
|
||||||
import com.pengrad.telegrambot.model.{Message, Update}
|
|
||||||
|
|
||||||
class OnMedicationNotifyApply (using coeur: MornyCoeur) extends EventListener {
|
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 = {
|
private def editedMessageProcess (edited: Message)(using event: EventEnv): Unit = {
|
||||||
if edited.chat.id != coeur.config.medicationNotifyToChat then return;
|
if edited.chat.id != coeur.config.medicationNotifyToChat then return;
|
||||||
coeur.daemons.medicationTimer.refreshNotificationWrite(edited)
|
if coeur.daemons.medicationTimer.refreshNotificationWrite(edited) then
|
||||||
event.setEventOk
|
event.setEventOk
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -4,7 +4,6 @@ import cc.sukazyo.cono.morny.bot.api.{EventEnv, EventListener}
|
|||||||
import cc.sukazyo.cono.morny.MornyCoeur
|
import cc.sukazyo.cono.morny.MornyCoeur
|
||||||
import cc.sukazyo.cono.morny.bot.event.OnQuestionMarkReply.isAllMessageMark
|
import cc.sukazyo.cono.morny.bot.event.OnQuestionMarkReply.isAllMessageMark
|
||||||
import cc.sukazyo.cono.morny.util.tgapi.TelegramExtensions.Bot.exec
|
import cc.sukazyo.cono.morny.util.tgapi.TelegramExtensions.Bot.exec
|
||||||
import com.pengrad.telegrambot.model.Update
|
|
||||||
import com.pengrad.telegrambot.request.SendMessage
|
import com.pengrad.telegrambot.request.SendMessage
|
||||||
|
|
||||||
import scala.language.postfixOps
|
import scala.language.postfixOps
|
||||||
@ -33,7 +32,9 @@ class OnQuestionMarkReply (using coeur: MornyCoeur) extends EventListener {
|
|||||||
|
|
||||||
object OnQuestionMarkReply {
|
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 = {
|
def isAllMessageMark (using text: String): Boolean = {
|
||||||
boundary[Boolean] {
|
boundary[Boolean] {
|
||||||
|
@ -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.bot.command.MornyCommands
|
||||||
import cc.sukazyo.cono.morny.util.tgapi.InputCommand
|
import cc.sukazyo.cono.morny.util.tgapi.InputCommand
|
||||||
import cc.sukazyo.cono.morny.Log.logger
|
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 = {
|
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"
|
logger trace s"got input command {$input} from event-context"
|
||||||
|
|
||||||
for ((name, command_instance) <- commands.commands_uni) {
|
for ((name, command_instance) <- commands.commands_uni) {
|
||||||
|
@ -12,7 +12,8 @@ class MornyQueries (using MornyCoeur) {
|
|||||||
RawText(),
|
RawText(),
|
||||||
MyInformation(),
|
MyInformation(),
|
||||||
ShareToolTwitter(),
|
ShareToolTwitter(),
|
||||||
ShareToolBilibili()
|
ShareToolBilibili(),
|
||||||
|
ShareToolSocialContent()
|
||||||
)
|
)
|
||||||
|
|
||||||
def query (event: Update): List[InlineQueryUnit[_]] = {
|
def query (event: Update): List[InlineQueryUnit[_]] = {
|
||||||
|
@ -24,7 +24,7 @@ class ShareToolBilibili (using coeur: MornyCoeur) extends ITelegramQuery {
|
|||||||
if (event.inlineQuery.query == null) return null
|
if (event.inlineQuery.query == null) return null
|
||||||
if (event.inlineQuery.query isBlank) 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 =
|
val result: BiliVideoId =
|
||||||
try
|
try
|
||||||
parse_videoUrl(event.inlineQuery.query)
|
parse_videoUrl(event.inlineQuery.query)
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -1,5 +1,7 @@
|
|||||||
package cc.sukazyo.cono.morny.bot.query
|
package cc.sukazyo.cono.morny.bot.query
|
||||||
|
|
||||||
|
import cc.sukazyo.cono.morny.extra.twitter
|
||||||
|
import cc.sukazyo.cono.morny.extra.twitter.TweetUrlInformation
|
||||||
import cc.sukazyo.cono.morny.util.tgapi.formatting.NamingUtils.inlineQueryId
|
import cc.sukazyo.cono.morny.util.tgapi.formatting.NamingUtils.inlineQueryId
|
||||||
import com.pengrad.telegrambot.model.Update
|
import com.pengrad.telegrambot.model.Update
|
||||||
import com.pengrad.telegrambot.model.request.InlineQueryResultArticle
|
import com.pengrad.telegrambot.model.request.InlineQueryResultArticle
|
||||||
@ -10,26 +12,25 @@ import scala.util.matching.Regex
|
|||||||
class ShareToolTwitter extends ITelegramQuery {
|
class ShareToolTwitter extends ITelegramQuery {
|
||||||
|
|
||||||
private val TITLE_VX = "[tweet] Share as VxTwitter"
|
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 = "[morny/share/twitter/vxtwi]"
|
||||||
private val ID_PREFIX_VX_COMBINED = "[morny/share/twitter/vxtwi_combine]"
|
private val TITLE_FX = "[tweet] Share as Fix-Tweet"
|
||||||
private val REGEX_TWEET_LINK: Regex = "^(?:https?://)?((?:(?:c\\.)?vx|fx|www\\.)?twitter\\.com)/((\\w+)/status/(\\d+)(?:/photo/(\\d+))?)/?(\\?[\\w&=-]+)?$"r
|
private val ID_PREFIX_FX = "[morny/share/twitter/fxtwi]"
|
||||||
|
|
||||||
override def query (event: Update): List[InlineQueryUnit[_]] | Null = {
|
override def query (event: Update): List[InlineQueryUnit[_]] | Null = {
|
||||||
|
|
||||||
if (event.inlineQuery.query == null) return 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(
|
List(
|
||||||
|
InlineQueryUnit(InlineQueryResultArticle(
|
||||||
|
inlineQueryId(ID_PREFIX_FX + event.inlineQuery.query), TITLE_FX,
|
||||||
|
s"https://fxtwitter.com/$_path_data"
|
||||||
|
)),
|
||||||
InlineQueryUnit(InlineQueryResultArticle(
|
InlineQueryUnit(InlineQueryResultArticle(
|
||||||
inlineQueryId(ID_PREFIX_VX+event.inlineQuery.query), TITLE_VX,
|
inlineQueryId(ID_PREFIX_VX+event.inlineQuery.query), TITLE_VX,
|
||||||
s"https://vxtwitter.com/$_path_data"
|
s"https://vxtwitter.com/$_path_data"
|
||||||
)),
|
|
||||||
InlineQueryUnit(InlineQueryResultArticle(
|
|
||||||
inlineQueryId(ID_PREFIX_VX_COMBINED+event.inlineQuery.query), TITLE_VX_COMBINED,
|
|
||||||
s"https://c.vxtwitter.com/$_path_data"
|
|
||||||
))
|
))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -42,7 +42,7 @@ class EventHacker (using coeur: MornyCoeur) {
|
|||||||
coeur.account exec SendMessage(
|
coeur.account exec SendMessage(
|
||||||
x.from_chat,
|
x.from_chat,
|
||||||
// language=html
|
// language=html
|
||||||
s"<code>${h(GsonBuilder().setPrettyPrinting().create.toJson(update))}</code>"
|
s"<pre><code class='language-json'>${h(GsonBuilder().setPrettyPrinting().create.toJson(update))}</code></pre>"
|
||||||
).parseMode(ParseMode HTML).replyToMessageId(x.from_message toInt)
|
).parseMode(ParseMode HTML).replyToMessageId(x.from_message toInt)
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
@ -1,19 +1,24 @@
|
|||||||
package cc.sukazyo.cono.morny.daemon
|
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.MornyCoeur
|
||||||
import cc.sukazyo.cono.morny.daemon.MedicationTimer.calcNextRoutineTimestamp
|
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.tgapi.TelegramExtensions.Bot.exec
|
||||||
import cc.sukazyo.cono.morny.util.CommonFormat
|
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.model.{Message, MessageEntity}
|
||||||
import com.pengrad.telegrambot.request.{EditMessageText, SendMessage}
|
import com.pengrad.telegrambot.request.{EditMessageText, SendMessage}
|
||||||
import com.pengrad.telegrambot.response.SendResponse
|
import com.pengrad.telegrambot.response.SendResponse
|
||||||
|
|
||||||
import java.time.{LocalDateTime, ZoneOffset}
|
import java.time.{Instant, ZonedDateTime, ZoneOffset}
|
||||||
import scala.collection.mutable.ArrayBuffer
|
import scala.collection.mutable.ArrayBuffer
|
||||||
import scala.language.implicitConversions
|
import scala.language.implicitConversions
|
||||||
|
|
||||||
class MedicationTimer (using coeur: MornyCoeur) extends Thread {
|
class MedicationTimer (using coeur: MornyCoeur) {
|
||||||
|
|
||||||
private val NOTIFY_MESSAGE = "🍥⏲"
|
private val NOTIFY_MESSAGE = "🍥⏲"
|
||||||
private val DAEMON_THREAD_NAME_DEF = "MedicationTimer"
|
private val DAEMON_THREAD_NAME_DEF = "MedicationTimer"
|
||||||
@ -23,53 +28,49 @@ class MedicationTimer (using coeur: MornyCoeur) extends Thread {
|
|||||||
private val notify_atHour: Set[Int] = coeur.config.medicationNotifyAt.asScala.toSet.map(_.intValue)
|
private val notify_atHour: Set[Int] = coeur.config.medicationNotifyAt.asScala.toSet.map(_.intValue)
|
||||||
private val notify_toChat = coeur.config.medicationNotifyToChat
|
private val notify_toChat = coeur.config.medicationNotifyToChat
|
||||||
|
|
||||||
this.setName(DAEMON_THREAD_NAME_DEF)
|
|
||||||
|
|
||||||
private var lastNotify_messageId: Option[Int] = None
|
private var lastNotify_messageId: Option[Int] = None
|
||||||
|
|
||||||
override def run (): Unit = {
|
private val scheduleTask: RoutineTask = new RoutineTask {
|
||||||
|
|
||||||
if ((notify_toChat == -1) || (notify_atHour isEmpty)) {
|
override def name: String = DAEMON_THREAD_NAME_DEF
|
||||||
logger notice "Medication Timer disabled : related param is not complete set"
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
logger notice "Medication Timer started."
|
def calcNextSendTime: EpochMillis =
|
||||||
while (!this.isInterrupted) {
|
val next_time = calcNextRoutineTimestamp(System.currentTimeMillis, use_timeZone, notify_atHour)
|
||||||
try {
|
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 next_time = calcNextRoutineTimestamp(System.currentTimeMillis, use_timeZone, notify_atHour)
|
next_time
|
||||||
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
|
override def firstRoutineTimeMillis: EpochMillis =
|
||||||
logger debug s"medication timer will sleep ${CommonFormat.formatDuration(sleep_millis)} [$sleep_millis]"
|
calcNextSendTime
|
||||||
Thread sleep sleep_millis
|
|
||||||
sendNotification()
|
override def nextRoutineTimeMillis (previousRoutineScheduledTimeMillis: EpochMillis): EpochMillis | Null =
|
||||||
logger info "medication notify sent."
|
calcNextSendTime
|
||||||
} catch
|
|
||||||
case _: InterruptedException =>
|
override def main: Unit = {
|
||||||
interrupt()
|
sendNotification()
|
||||||
logger notice "MedicationTimer was interrupted, will be exit now"
|
logger info "medication notify sent."
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
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 = {
|
private def sendNotification(): Unit = {
|
||||||
val sendResponse: SendResponse = coeur.account exec SendMessage(notify_toChat, NOTIFY_MESSAGE)
|
val sendResponse: SendResponse = coeur.account exec SendMessage(notify_toChat, NOTIFY_MESSAGE)
|
||||||
if sendResponse isOk then lastNotify_messageId = Some(sendResponse.message.messageId)
|
if sendResponse isOk then lastNotify_messageId = Some(sendResponse.message.messageId)
|
||||||
else lastNotify_messageId = None
|
else lastNotify_messageId = None
|
||||||
}
|
}
|
||||||
|
|
||||||
def refreshNotificationWrite (edited: Message): Unit = {
|
def refreshNotificationWrite (edited: Message): Boolean = {
|
||||||
if (lastNotify_messageId isEmpty) || (lastNotify_messageId.get != (edited.messageId toInt)) then return
|
if (lastNotify_messageId isEmpty) || (lastNotify_messageId.get != (edited.messageId toInt)) then return false
|
||||||
import cc.sukazyo.cono.morny.util.CommonFormat.formatDate
|
import cc.sukazyo.cono.morny.util.CommonFormat.formatDate
|
||||||
val editTime = formatDate(edited.editDate*1000, use_timeZone.getTotalSeconds/60/60)
|
val editTime = formatDate(edited.editDate*1000, use_timeZone.getTotalSeconds/60/60)
|
||||||
val entities = ArrayBuffer.empty[MessageEntity]
|
val entities = ArrayBuffer.empty[MessageEntity]
|
||||||
@ -81,24 +82,31 @@ class MedicationTimer (using coeur: MornyCoeur) extends Thread {
|
|||||||
edited.text + s"\n-- $editTime --"
|
edited.text + s"\n-- $editTime --"
|
||||||
).entities(entities toArray:_*)
|
).entities(entities toArray:_*)
|
||||||
lastNotify_messageId = None
|
lastNotify_messageId = None
|
||||||
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
object MedicationTimer {
|
object MedicationTimer {
|
||||||
|
|
||||||
|
//noinspection ScalaWeakerAccess
|
||||||
|
val cronDef: CronDefinition = CronDefinitionBuilder.defineCron
|
||||||
|
.withHours.and
|
||||||
|
.instance
|
||||||
|
|
||||||
@throws[IllegalArgumentException]
|
@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")
|
if (notifyAt isEmpty) throw new IllegalArgumentException("notify time is not set")
|
||||||
var time = LocalDateTime.ofEpochSecond(
|
import com.cronutils.model.field.expression.FieldExpressionFactory.*
|
||||||
baseTimeMillis / 1000, ((baseTimeMillis % 1000) * 1000 * 1000) toInt,
|
ExecutionTime.forCron(CronBuilder.cron(cronDef)
|
||||||
zone
|
.withHour(and({
|
||||||
).withMinute(0).withSecond(0).withNano(0)
|
import scala.jdk.CollectionConverters.*
|
||||||
time = time plusHours 1
|
(for (i <- notifyAt) yield on(i)).toList.asJava
|
||||||
while (!(notifyAt contains(time getHour))) {
|
}))
|
||||||
time = time plusHours 1
|
.instance
|
||||||
}
|
).nextExecution(
|
||||||
(time toInstant zone) toEpochMilli
|
ZonedDateTime ofInstant (Instant ofEpochMilli baseTimeMillis, zone.normalized)
|
||||||
|
).get.toInstant.toEpochMilli
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -13,8 +13,8 @@ class MornyDaemons (using val coeur: MornyCoeur) {
|
|||||||
|
|
||||||
logger notice "ALL Morny Daemons starting..."
|
logger notice "ALL Morny Daemons starting..."
|
||||||
|
|
||||||
// TrackerDataManager.init();
|
|
||||||
medicationTimer.start()
|
medicationTimer.start()
|
||||||
|
reporter.start()
|
||||||
|
|
||||||
logger notice "Morny Daemons started."
|
logger notice "Morny Daemons started."
|
||||||
|
|
||||||
@ -24,12 +24,8 @@ class MornyDaemons (using val coeur: MornyCoeur) {
|
|||||||
|
|
||||||
logger notice "stopping All Morny Daemons..."
|
logger notice "stopping All Morny Daemons..."
|
||||||
|
|
||||||
// TrackerDataManager.DAEMON.interrupt();
|
medicationTimer.stop()
|
||||||
medicationTimer.interrupt()
|
reporter.stop()
|
||||||
// TrackerDataManager.trackingLock.lock();
|
|
||||||
try { medicationTimer.join() }
|
|
||||||
catch case e: InterruptedException =>
|
|
||||||
e.printStackTrace(System.out)
|
|
||||||
|
|
||||||
logger notice "stopped ALL Morny Daemons."
|
logger notice "stopped ALL Morny Daemons."
|
||||||
}
|
}
|
||||||
|
@ -2,16 +2,26 @@ package cc.sukazyo.cono.morny.daemon
|
|||||||
|
|
||||||
import cc.sukazyo.cono.morny.{MornyCoeur, MornyConfig}
|
import cc.sukazyo.cono.morny.{MornyCoeur, MornyConfig}
|
||||||
import cc.sukazyo.cono.morny.Log.{exceptionLog, logger}
|
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.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.event.EventRuntimeException
|
||||||
import cc.sukazyo.cono.morny.util.tgapi.formatting.TelegramFormatter.*
|
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.formatting.TelegramParseEscape.escapeHtml as h
|
||||||
import cc.sukazyo.cono.morny.util.tgapi.TelegramExtensions.Bot.exec
|
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.google.gson.GsonBuilder
|
||||||
import com.pengrad.telegrambot.model.request.ParseMode
|
import com.pengrad.telegrambot.model.request.ParseMode
|
||||||
import com.pengrad.telegrambot.model.User
|
import com.pengrad.telegrambot.model.User
|
||||||
import com.pengrad.telegrambot.request.{BaseRequest, SendMessage}
|
import com.pengrad.telegrambot.request.{BaseRequest, SendMessage}
|
||||||
import com.pengrad.telegrambot.response.BaseResponse
|
import com.pengrad.telegrambot.response.BaseResponse
|
||||||
|
import com.pengrad.telegrambot.TelegramException
|
||||||
|
|
||||||
|
import java.time.ZoneId
|
||||||
|
|
||||||
class MornyReport (using coeur: MornyCoeur) {
|
class MornyReport (using coeur: MornyCoeur) {
|
||||||
|
|
||||||
@ -23,13 +33,20 @@ class MornyReport (using coeur: MornyCoeur) {
|
|||||||
if !enabled then return;
|
if !enabled then return;
|
||||||
try {
|
try {
|
||||||
coeur.account exec report
|
coeur.account exec report
|
||||||
} catch case e: EventRuntimeException.ActionFailed => {
|
} catch case e: EventRuntimeException => {
|
||||||
logger warn
|
import EventRuntimeException.*
|
||||||
s"""cannot execute report to telegram:
|
e match
|
||||||
|${exceptionLog(e) indent 4}
|
case e: ActionFailed =>
|
||||||
| tg-api response:
|
logger warn
|
||||||
|${(e.response toString) indent 4}"""
|
s"""cannot execute report to telegram:
|
||||||
.stripMargin
|
|${exceptionLog(e) indent 4}
|
||||||
|
| tg-api response:
|
||||||
|
|${(e.response toString) indent 4}""".stripMargin
|
||||||
|
case e: ClientFailed =>
|
||||||
|
logger error
|
||||||
|
s"""failed when report to telegram:
|
||||||
|
|${exceptionLog(e.getCause) indent 4}
|
||||||
|
|""".stripMargin
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -37,15 +54,19 @@ class MornyReport (using coeur: MornyCoeur) {
|
|||||||
def _tgErrFormat: String = e match
|
def _tgErrFormat: String = e match
|
||||||
case api: EventRuntimeException.ActionFailed =>
|
case api: EventRuntimeException.ActionFailed =>
|
||||||
// language=html
|
// language=html
|
||||||
"\n\ntg-api error:\n<pre><code>%s</code></pre>"
|
"\n\ntg-api error:\n<pre><code class='language-json'>%s</code></pre>"
|
||||||
.formatted(GsonBuilder().setPrettyPrinting().create.toJson(api.response))
|
.formatted(GsonBuilder().setPrettyPrinting().create.toJson(api.response))
|
||||||
|
case tgErr: TelegramException if tgErr.response != null =>
|
||||||
|
// language=html
|
||||||
|
"\n\ntg-api error:\n<pre><code class='language-json'>%s</code></pre>"
|
||||||
|
.formatted(GsonBuilder().setPrettyPrinting().create.toJson(tgErr.response))
|
||||||
case _ => ""
|
case _ => ""
|
||||||
executeReport(SendMessage(
|
executeReport(SendMessage(
|
||||||
coeur.config.reportToChat,
|
coeur.config.reportToChat,
|
||||||
// language=html
|
// language=html
|
||||||
s"""<b>▌Coeur Unexpected Exception </b>
|
s"""<b>▌Coeur Unexpected Exception </b>
|
||||||
|${if description ne null then h(description)+"\n" else ""}
|
|${if description ne null then h(description)+"\n" else ""}
|
||||||
|<pre><code>${h(exceptionLog(e))}</code></pre>$_tgErrFormat"""
|
|<pre><code class="language-log">${h(exceptionLog(e))}</code></pre>$_tgErrFormat"""
|
||||||
.stripMargin
|
.stripMargin
|
||||||
).parseMode(ParseMode HTML))
|
).parseMode(ParseMode HTML))
|
||||||
}
|
}
|
||||||
@ -67,10 +88,12 @@ class MornyReport (using coeur: MornyCoeur) {
|
|||||||
// language=html
|
// language=html
|
||||||
s"""<b>▌Morny Logged in</b>
|
s"""<b>▌Morny Logged in</b>
|
||||||
|-v $getVersionAllFullTagHTML
|
|-v $getVersionAllFullTagHTML
|
||||||
|as user @${coeur.username}
|
|Logged into user: @${coeur.username}
|
||||||
|
|
|
|
||||||
|as config fields:
|
|as config fields:
|
||||||
|${sectionConfigFields(coeur.config)}"""
|
|${sectionConfigFields(coeur.config)}
|
||||||
|
|
|
||||||
|
|Report Daemon will use TimeZone <code>${coeur.config.reportZone.getDisplayName}</code> for following report."""
|
||||||
.stripMargin
|
.stripMargin
|
||||||
).parseMode(ParseMode HTML))
|
).parseMode(ParseMode HTML))
|
||||||
}
|
}
|
||||||
@ -120,4 +143,106 @@ class MornyReport (using coeur: MornyCoeur) {
|
|||||||
).parseMode(ParseMode HTML))
|
).parseMode(ParseMode HTML))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
object EventStatistics {
|
||||||
|
|
||||||
|
private var eventTotal = 0
|
||||||
|
private var eventCanceled = 0
|
||||||
|
private val runningTime: NumericStatistics[DurationMillis] = NumericStatistics()
|
||||||
|
|
||||||
|
def reset (): Unit = {
|
||||||
|
eventTotal = 0
|
||||||
|
eventCanceled = 0
|
||||||
|
runningTime.reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
private def runningTimeStatisticsHTML: String =
|
||||||
|
runningTime.value match
|
||||||
|
// language=html
|
||||||
|
case None => "<i><u><no-statistics></u></i>"
|
||||||
|
case Some(value) =>
|
||||||
|
import cc.sukazyo.cono.morny.util.CommonFormat.formatDuration as f
|
||||||
|
s""" - <i>average</i>: <code>${f(value.total / value.count)}</code>
|
||||||
|
| - <i>max time</i>: <code>${f(value.max)}</code>
|
||||||
|
| - <i>min time</i>: <code>${f(value.min)}</code>
|
||||||
|
| - <i>total</i>: <code>${f(value.total)}</code>""".stripMargin
|
||||||
|
|
||||||
|
def eventStatisticsHTML: String =
|
||||||
|
import cc.sukazyo.cono.morny.util.UseMath.percentageOf as p
|
||||||
|
val processed = runningTime.count
|
||||||
|
val canceled = eventCanceled
|
||||||
|
val ignored = eventTotal - processed - canceled
|
||||||
|
// language=html
|
||||||
|
s""" - <i>total event received</i>: <code>$eventTotal</code>
|
||||||
|
| - <i>event ignored</i>: (<code>${eventTotal p ignored}%</code>) <code>$ignored</code>
|
||||||
|
| - <i>event canceled</i>: (<code>${eventTotal p canceled}%</code>) <code>$canceled</code>
|
||||||
|
| - <i>event processed</i>: (<code>${eventTotal p processed}%</code>) <code>$processed</code>
|
||||||
|
| - <i>processed time usage</i>:
|
||||||
|
|${runningTimeStatisticsHTML.indent(3)}""".stripMargin
|
||||||
|
|
||||||
|
object EventInfoCatcher extends EventListener {
|
||||||
|
override def executeFilter (using EventEnv): Boolean = true
|
||||||
|
//noinspection ScalaWeakerAccess
|
||||||
|
case class EventTimeUsed (it: DurationMillis)
|
||||||
|
override def atEventPost (using event: EventEnv): Unit = {
|
||||||
|
import event.State
|
||||||
|
eventTotal += 1
|
||||||
|
event.state match
|
||||||
|
case State.OK(from) =>
|
||||||
|
val timeUsed = EventTimeUsed(System.currentTimeMillis - event.timeStartup)
|
||||||
|
event provide timeUsed
|
||||||
|
logger debug
|
||||||
|
s"""event done with OK
|
||||||
|
| with time consumed ${timeUsed.it}ms
|
||||||
|
| by $from""".stripMargin
|
||||||
|
runningTime ++ timeUsed.it
|
||||||
|
case State.CANCELED(from) =>
|
||||||
|
eventCanceled += 1
|
||||||
|
logger debug
|
||||||
|
s"""event done with CANCELED"
|
||||||
|
| by $from""".stripMargin
|
||||||
|
case null =>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private object DailyReportTask extends CronTask {
|
||||||
|
|
||||||
|
import com.cronutils.model.field.expression.FieldExpressionFactory.*
|
||||||
|
|
||||||
|
override val name: String = "reporter#event"
|
||||||
|
override val cron: Cron = CronBuilder.cron(
|
||||||
|
CronDefinitionBuilder.defineCron
|
||||||
|
.withHours.and
|
||||||
|
.instance
|
||||||
|
).withHour(on(0)).instance
|
||||||
|
override val zone: ZoneId = coeur.config.reportZone.toZoneId
|
||||||
|
|
||||||
|
//noinspection TypeAnnotation
|
||||||
|
override def main = {
|
||||||
|
|
||||||
|
executeReport(SendMessage(
|
||||||
|
coeur.config.reportToChat,
|
||||||
|
// language=html
|
||||||
|
s"""▌Morny Daily Report
|
||||||
|
|
|
||||||
|
|<b>Event Statistics :</b>
|
||||||
|
|${EventStatistics.eventStatisticsHTML}""".stripMargin
|
||||||
|
).parseMode(ParseMode.HTML))
|
||||||
|
|
||||||
|
// daily reset
|
||||||
|
EventStatistics.reset()
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
def start (): Unit = {
|
||||||
|
coeur.tasks ++ DailyReportTask
|
||||||
|
}
|
||||||
|
|
||||||
|
def stop (): Unit = {
|
||||||
|
coeur.tasks % DailyReportTask
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -1,15 +1,16 @@
|
|||||||
package cc.sukazyo.cono.morny.data
|
package cc.sukazyo.cono.morny.data
|
||||||
|
|
||||||
|
import cc.sukazyo.cono.morny.util.EpochDateTime.{EpochDays, EpochMillis}
|
||||||
import com.pengrad.telegrambot.model.User
|
import com.pengrad.telegrambot.model.User
|
||||||
|
|
||||||
import scala.language.postfixOps
|
import scala.language.postfixOps
|
||||||
|
|
||||||
object MornyJrrp {
|
object MornyJrrp {
|
||||||
|
|
||||||
def jrrp_of_telegramUser (user: User, timestamp: Long): Double =
|
def jrrp_of_telegramUser (user: User, timestamp: EpochMillis): Double =
|
||||||
jrrp_v_xmomi(user.id, timestamp/(1000*60*60*24)) * 100.0
|
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.CommonEncrypt.MD5
|
||||||
import cc.sukazyo.cono.morny.util.ConvertByteHex.toHex
|
import cc.sukazyo.cono.morny.util.ConvertByteHex.toHex
|
||||||
java.lang.Long.parseLong(MD5(s"$identifier@$dayStamp").toHex.substring(0, 4), 16) / (0xffff toDouble)
|
java.lang.Long.parseLong(MD5(s"$identifier@$dayStamp").toHex.substring(0, 4), 16) / (0xffff toDouble)
|
||||||
|
@ -0,0 +1,114 @@
|
|||||||
|
package cc.sukazyo.cono.morny.data.social
|
||||||
|
|
||||||
|
import cc.sukazyo.cono.morny.data.social.SocialContent.{SocialMedia, SocialMediaType, SocialMediaWithUrl}
|
||||||
|
import cc.sukazyo.cono.morny.data.social.SocialContent.SocialMediaType.{Photo, Video}
|
||||||
|
import cc.sukazyo.cono.morny.MornyCoeur
|
||||||
|
import cc.sukazyo.cono.morny.bot.query.InlineQueryUnit
|
||||||
|
import cc.sukazyo.cono.morny.util.tgapi.TelegramExtensions.Bot.exec
|
||||||
|
import cc.sukazyo.cono.morny.util.tgapi.formatting.NamingUtils.inlineQueryId
|
||||||
|
import com.pengrad.telegrambot.model.request.*
|
||||||
|
import com.pengrad.telegrambot.request.{SendMediaGroup, SendMessage}
|
||||||
|
|
||||||
|
/** Model of social networks' status. for example twitter tweet or
|
||||||
|
* weibo status.
|
||||||
|
*
|
||||||
|
* Can be output to Telegram.
|
||||||
|
*
|
||||||
|
* @param text_html Formatted HTML output of the status that can be output
|
||||||
|
* directly to Telegram. Normally will contains metadata
|
||||||
|
* like status' author or like count etc.
|
||||||
|
* @param text_withPicPlaceholder same with [[text_html]], but contains more
|
||||||
|
* placeholder texts of medias. can be used
|
||||||
|
* when medias cannot be output.
|
||||||
|
* @param medias Status attachment medias.
|
||||||
|
* @param medias_mosaic Mosaic version of status medias. Will be used when
|
||||||
|
* the output API doesn't support multiple medias like
|
||||||
|
* Telegram inline API. This value is depends on the specific
|
||||||
|
* backend parser/formatter implementation.
|
||||||
|
* @param thumbnail Medias' thumbnail. Will be used when the output API required
|
||||||
|
* a thumbnail. This value is depends on the specific backend
|
||||||
|
* parser/formatter implementation.
|
||||||
|
*/
|
||||||
|
case class SocialContent (
|
||||||
|
text_html: String,
|
||||||
|
text_withPicPlaceholder: String,
|
||||||
|
medias: List[SocialMedia],
|
||||||
|
medias_mosaic: Option[SocialMedia] = None,
|
||||||
|
thumbnail: Option[SocialMedia] = None
|
||||||
|
) {
|
||||||
|
|
||||||
|
def thumbnailOrElse[T] (orElse: T): String | T =
|
||||||
|
thumbnail match
|
||||||
|
case Some(x) if x.isInstanceOf[SocialMediaWithUrl] && x.t == Photo =>
|
||||||
|
x.asInstanceOf[SocialMediaWithUrl].url
|
||||||
|
case _ => orElse
|
||||||
|
|
||||||
|
def outputToTelegram (using replyChat: Long, replyToMessage: Int)(using coeur: MornyCoeur): Unit = {
|
||||||
|
if medias isEmpty then
|
||||||
|
coeur.account exec
|
||||||
|
SendMessage(replyChat, text_html)
|
||||||
|
.parseMode(ParseMode.HTML)
|
||||||
|
.replyToMessageId(replyToMessage)
|
||||||
|
else
|
||||||
|
val mediaGroup = medias.map(f => f.genTelegramInputMedia)
|
||||||
|
mediaGroup.head.caption(text_html)
|
||||||
|
mediaGroup.head.parseMode(ParseMode.HTML)
|
||||||
|
coeur.account exec
|
||||||
|
SendMediaGroup(replyChat, mediaGroup: _*)
|
||||||
|
.replyToMessageId(replyToMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
def genInlineQueryResults (using id_head: String, id_param: Any, name: String): List[InlineQueryUnit[?]] = {
|
||||||
|
(
|
||||||
|
if (medias_mosaic nonEmpty) && (medias_mosaic.get.t == Photo) && medias_mosaic.get.isInstanceOf[SocialMediaWithUrl] then
|
||||||
|
InlineQueryUnit(InlineQueryResultPhoto(
|
||||||
|
inlineQueryId(s"[$id_head/photo/mosaic]$id_param"),
|
||||||
|
medias_mosaic.get.asInstanceOf[SocialMediaWithUrl].url,
|
||||||
|
thumbnailOrElse(medias_mosaic.get.asInstanceOf[SocialMediaWithUrl].url)
|
||||||
|
).title(s"$name").caption(text_html).parseMode(ParseMode.HTML)) :: Nil
|
||||||
|
else if (medias nonEmpty) && (medias.head.t == Photo) then
|
||||||
|
val media = medias.head
|
||||||
|
media match
|
||||||
|
case media_url: SocialMediaWithUrl =>
|
||||||
|
InlineQueryUnit(InlineQueryResultPhoto(
|
||||||
|
inlineQueryId(s"[$id_head/photo/0]$id_param"),
|
||||||
|
media_url.url,
|
||||||
|
thumbnailOrElse(media_url.url)
|
||||||
|
).title(s"$name").caption(text_html).parseMode(ParseMode.HTML)) :: Nil
|
||||||
|
case _ =>
|
||||||
|
InlineQueryUnit(InlineQueryResultArticle(
|
||||||
|
inlineQueryId(s"[$id_head/text_only]$id_param"), s"$name (text only)",
|
||||||
|
InputTextMessageContent(text_withPicPlaceholder).parseMode(ParseMode.HTML)
|
||||||
|
)) :: Nil
|
||||||
|
else
|
||||||
|
InlineQueryUnit(InlineQueryResultArticle(
|
||||||
|
inlineQueryId(s"[$id_head/text]$id_param"), s"$name",
|
||||||
|
InputTextMessageContent(text_html).parseMode(ParseMode.HTML)
|
||||||
|
)) :: Nil
|
||||||
|
) ::: Nil
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
object SocialContent {
|
||||||
|
|
||||||
|
enum SocialMediaType:
|
||||||
|
case Photo
|
||||||
|
case Video
|
||||||
|
sealed trait SocialMedia(val t: SocialMediaType) {
|
||||||
|
def genTelegramInputMedia: InputMedia[?]
|
||||||
|
}
|
||||||
|
case class SocialMediaWithUrl (url: String)(t: SocialMediaType) extends SocialMedia(t) {
|
||||||
|
override def genTelegramInputMedia: InputMedia[_] =
|
||||||
|
t match
|
||||||
|
case Photo => InputMediaPhoto(url)
|
||||||
|
case Video => InputMediaVideo(url)
|
||||||
|
}
|
||||||
|
case class SocialMediaWithBytesData (data: Array[Byte])(t: SocialMediaType) extends SocialMedia(t) {
|
||||||
|
override def genTelegramInputMedia: InputMedia[_] =
|
||||||
|
t match
|
||||||
|
case Photo => InputMediaPhoto(data)
|
||||||
|
case Video => InputMediaVideo(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,66 @@
|
|||||||
|
package cc.sukazyo.cono.morny.data.social
|
||||||
|
|
||||||
|
import cc.sukazyo.cono.morny.data.social.SocialContent.{SocialMedia, SocialMediaWithUrl}
|
||||||
|
import cc.sukazyo.cono.morny.data.social.SocialContent.SocialMediaType.{Photo, Video}
|
||||||
|
import cc.sukazyo.cono.morny.extra.twitter.{FXApi, FXTweet}
|
||||||
|
import cc.sukazyo.cono.morny.util.tgapi.formatting.TelegramParseEscape.escapeHtml as h
|
||||||
|
|
||||||
|
object SocialTwitterParser {
|
||||||
|
|
||||||
|
def parseFXTweet_forMediaPlaceholderInContent (tweet: FXTweet): String =
|
||||||
|
tweet.media match
|
||||||
|
case None => ""
|
||||||
|
case Some(media) =>
|
||||||
|
"\n" + (media.photos.getOrElse(Nil).map(* => "🖼️") ::: media.videos.getOrElse(Nil).map(* => "🎞️"))
|
||||||
|
.mkString(" ")
|
||||||
|
|
||||||
|
def parseFXTweet (api: FXApi): SocialContent = {
|
||||||
|
api.tweet match
|
||||||
|
case None =>
|
||||||
|
val content =
|
||||||
|
// language=html
|
||||||
|
s"""❌ Fix-Tweet <code>${api.code}</code>
|
||||||
|
|<i>${h(api.message)}</i>""".stripMargin
|
||||||
|
SocialContent(content, content, Nil)
|
||||||
|
case Some(tweet) =>
|
||||||
|
val content: String =
|
||||||
|
// language=html
|
||||||
|
s"""⚪️ <b>${h(tweet.author.name)} <a href="${tweet.author.url}">@${h(tweet.author.screen_name)}</a></b>
|
||||||
|
|
|
||||||
|
|${h(tweet.text)}
|
||||||
|
|
|
||||||
|
|<i>💬${tweet.replies} 🔗${tweet.retweets} ❤️${tweet.likes}</i>
|
||||||
|
|<i><a href="${tweet.url}">${h(tweet.created_at)}</a></i>""".stripMargin
|
||||||
|
val content_withMediasPlaceholder: String =
|
||||||
|
// language=html
|
||||||
|
s"""⚪️ <b>${h(tweet.author.name)} <a href="${tweet.author.url}">@${h(tweet.author.screen_name)}</a></b>
|
||||||
|
|
|
||||||
|
|${h(tweet.text)}${parseFXTweet_forMediaPlaceholderInContent(tweet)}
|
||||||
|
|
|
||||||
|
|<i>💬${tweet.replies} 🔗${tweet.retweets} ❤️${tweet.likes}</i>
|
||||||
|
|<i><a href="${tweet.url}">${h(tweet.created_at)}</a></i>""".stripMargin
|
||||||
|
tweet.media match
|
||||||
|
case None =>
|
||||||
|
SocialContent(content, content_withMediasPlaceholder, Nil)
|
||||||
|
case Some(media) =>
|
||||||
|
val mediaGroup: List[SocialMedia] =
|
||||||
|
(
|
||||||
|
media.photos match
|
||||||
|
case None => List.empty
|
||||||
|
case Some(photos) => for i <- photos yield SocialMediaWithUrl(i.url)(Photo)
|
||||||
|
) ::: (
|
||||||
|
media.videos match
|
||||||
|
case None => List.empty
|
||||||
|
case Some(videos) => for i <- videos yield SocialMediaWithUrl(i.url)(Video)
|
||||||
|
)
|
||||||
|
val thumbnail =
|
||||||
|
if media.videos.nonEmpty then
|
||||||
|
Some(SocialMediaWithUrl(media.videos.get.head.thumbnail_url)(Photo))
|
||||||
|
else None
|
||||||
|
val mediaMosaic = media.mosaic match
|
||||||
|
case Some(mosaic) => Some(SocialMediaWithUrl(mosaic.formats.jpeg)(Photo))
|
||||||
|
case None => None
|
||||||
|
SocialContent(content, content_withMediasPlaceholder, mediaGroup, mediaMosaic, thumbnail)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,50 @@
|
|||||||
|
package cc.sukazyo.cono.morny.data.social
|
||||||
|
|
||||||
|
import cc.sukazyo.cono.morny.data.social.SocialContent.SocialMediaType.Photo
|
||||||
|
import cc.sukazyo.cono.morny.data.social.SocialContent.SocialMediaWithBytesData
|
||||||
|
import cc.sukazyo.cono.morny.extra.weibo.{genWeiboStatusUrl, MApi, MStatus, StatusUrlInfo}
|
||||||
|
import cc.sukazyo.cono.morny.util.tgapi.formatting.TelegramParseEscape.{cleanupHtml as ch, escapeHtml as h}
|
||||||
|
import io.circe.{DecodingFailure, ParsingFailure}
|
||||||
|
import sttp.client3.{HttpError, SttpClientException}
|
||||||
|
|
||||||
|
object SocialWeiboParser {
|
||||||
|
|
||||||
|
def parseMStatus_forPicPreview (status: MStatus): String =
|
||||||
|
if status.pic_ids.isEmpty then "" else
|
||||||
|
"\n" + (for (pic <- status.pic_ids) yield "🖼️").mkString(" ")
|
||||||
|
|
||||||
|
def parseMStatus_forRetweeted (originalStatus: MStatus): String =
|
||||||
|
originalStatus.retweeted_status match
|
||||||
|
case Some(status) =>
|
||||||
|
// language=html
|
||||||
|
s"""
|
||||||
|
|<i>//<a href="https://weibo.com/${status.user.id}/${status.id}">${h(status.user.screen_name)}</a>:</i>
|
||||||
|
|${ch(status.text)}${parseMStatus_forPicPreview(status)}
|
||||||
|
|""".stripMargin
|
||||||
|
case None => ""
|
||||||
|
|
||||||
|
@throws[HttpError[?] | SttpClientException | ParsingFailure | DecodingFailure]
|
||||||
|
def parseMStatus (api: MApi[MStatus]): SocialContent = {
|
||||||
|
val content =
|
||||||
|
// language=html
|
||||||
|
s"""🔸<b><a href="${api.data.user.profile_url}">${h(api.data.user.screen_name)}</a></b>
|
||||||
|
|
|
||||||
|
|${ch(api.data.text)}
|
||||||
|
|${parseMStatus_forRetweeted(api.data)}
|
||||||
|
|<i><a href="${genWeiboStatusUrl(StatusUrlInfo(api.data.user.id.toString, api.data.id))}">${h(api.data.created_at)}</a></i>""".stripMargin
|
||||||
|
val content_withPicPlaceholder =
|
||||||
|
// language=html
|
||||||
|
s"""🔸<b><a href="${api.data.user.profile_url}">${h(api.data.user.screen_name)}</a></b>
|
||||||
|
|
|
||||||
|
|${ch(api.data.text)}${parseMStatus_forPicPreview(api.data)}
|
||||||
|
|${parseMStatus_forRetweeted(api.data)}
|
||||||
|
|<i><a href="${genWeiboStatusUrl(StatusUrlInfo(api.data.user.id.toString, api.data.id))}">${h(api.data.created_at)}</a></i>""".stripMargin
|
||||||
|
api.data.pics match
|
||||||
|
case None =>
|
||||||
|
SocialContent(content, content_withPicPlaceholder, Nil)
|
||||||
|
case Some(pics) =>
|
||||||
|
val mediaGroup = pics.map(f => SocialMediaWithBytesData(MApi.Fetch.pic(f.large.url))(Photo))
|
||||||
|
SocialContent(content, content_withPicPlaceholder, mediaGroup)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -1,9 +1,9 @@
|
|||||||
package cc.sukazyo.cono.morny.data
|
package cc.sukazyo.cono.morny.extra
|
||||||
|
|
||||||
import cc.sukazyo.cono.morny.util.BiliTool
|
import cc.sukazyo.cono.morny.util.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 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.client3.okhttp.OkHttpSyncBackend
|
||||||
import sttp.model.Uri
|
import sttp.model.Uri
|
||||||
|
|
||||||
@ -77,7 +77,8 @@ object BilibiliForms {
|
|||||||
throw IllegalArgumentException(s"is a b23 video link: $uri . (use parse_videoUrl instead)")
|
throw IllegalArgumentException(s"is a b23 video link: $uri . (use parse_videoUrl instead)")
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val response = basicRequest
|
import sttp.client3.ignore
|
||||||
|
val response = mornyBasicRequest
|
||||||
.get(uri)
|
.get(uri)
|
||||||
.followRedirects(false)
|
.followRedirects(false)
|
||||||
.response(ignore)
|
.response(ignore)
|
@ -1,7 +1,8 @@
|
|||||||
package cc.sukazyo.cono.morny.data
|
package cc.sukazyo.cono.morny.extra
|
||||||
|
|
||||||
|
import cc.sukazyo.cono.morny.util.SttpPublic.mornyBasicRequest
|
||||||
import com.google.gson.Gson
|
import 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.client3.okhttp.OkHttpSyncBackend
|
||||||
import sttp.model.MediaType
|
import sttp.model.MediaType
|
||||||
|
|
||||||
@ -22,7 +23,7 @@ object NbnhhshQuery {
|
|||||||
@throws[HttpError[_]|SttpClientException]
|
@throws[HttpError[_]|SttpClientException]
|
||||||
def sendGuess (text: String): GuessResult = {
|
def sendGuess (text: String): GuessResult = {
|
||||||
case class GuessRequest (text: String)
|
case class GuessRequest (text: String)
|
||||||
val http = basicRequest
|
val http = mornyBasicRequest
|
||||||
.body(Gson().toJson(GuessRequest(text))).contentType(MediaType.ApplicationJson)
|
.body(Gson().toJson(GuessRequest(text))).contentType(MediaType.ApplicationJson)
|
||||||
.post(API_GUESS_METHOD)
|
.post(API_GUESS_METHOD)
|
||||||
.response(asString.getRight)
|
.response(asString.getRight)
|
@ -1,7 +1,7 @@
|
|||||||
package cc.sukazyo.cono.morny.data.ip186
|
package cc.sukazyo.cono.morny.extra.ip186
|
||||||
|
|
||||||
import cc.sukazyo.cono.morny.util.SttpPublic.Schemes
|
import cc.sukazyo.cono.morny.util.SttpPublic.{mornyBasicRequest, Schemes}
|
||||||
import sttp.client3.{asString, basicRequest, HttpError, SttpClientException, UriContext}
|
import sttp.client3.{asString, HttpError, SttpClientException, UriContext}
|
||||||
import sttp.client3.okhttp.OkHttpSyncBackend
|
import sttp.client3.okhttp.OkHttpSyncBackend
|
||||||
import sttp.model.Uri
|
import sttp.model.Uri
|
||||||
|
|
||||||
@ -36,7 +36,7 @@ object IP186QueryHandler {
|
|||||||
val uri = requestPath.scheme(Schemes.HTTPS).host(SITE_HOST)
|
val uri = requestPath.scheme(Schemes.HTTPS).host(SITE_HOST)
|
||||||
IP186Response(
|
IP186Response(
|
||||||
uri.toString,
|
uri.toString,
|
||||||
basicRequest
|
mornyBasicRequest
|
||||||
.get(uri)
|
.get(uri)
|
||||||
.response(asString.getRight)
|
.response(asString.getRight)
|
||||||
.send(httpClient)
|
.send(httpClient)
|
@ -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)
|
case class IP186Response (url: String, body: String)
|
107
src/main/scala/cc/sukazyo/cono/morny/extra/twitter/FXApi.scala
Normal file
107
src/main/scala/cc/sukazyo/cono/morny/extra/twitter/FXApi.scala
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
package cc.sukazyo.cono.morny.extra.twitter
|
||||||
|
|
||||||
|
import cc.sukazyo.cono.morny.util.SttpPublic
|
||||||
|
import cc.sukazyo.cono.morny.util.SttpPublic.mornyBasicRequest
|
||||||
|
import io.circe.{DecodingFailure, ParsingFailure}
|
||||||
|
|
||||||
|
/** The struct of FixTweet Status-Fetch-API response.
|
||||||
|
*
|
||||||
|
* It may have some issues due to the API reference from FixTweet
|
||||||
|
* project is very outdated and inaccurate.
|
||||||
|
*
|
||||||
|
* @see [[https://github.com/FixTweet/FixTweet/wiki/Status-Fetch-API]]
|
||||||
|
*
|
||||||
|
* @param code Status code, normally be [[200]], but can be 401
|
||||||
|
* or [[404]] or [[500]] due to different reasons.
|
||||||
|
*
|
||||||
|
* Related to [[message]]
|
||||||
|
* @param message Status message.
|
||||||
|
*
|
||||||
|
* - When [[code]] is [[200]], it should be `OK`
|
||||||
|
* - When [[code]] is [[401]], it should be `PRIVATE_TWEET`,
|
||||||
|
* while in practice, it seems PRIVATE_TWEET will
|
||||||
|
* just return [[404]].
|
||||||
|
* - When [[code]] is [[404]], it should be `NOT_FOUND`
|
||||||
|
* - When [[code]] is [[500]], it should be `API_FILE`
|
||||||
|
* @param tweet [[FXTweet]] content.
|
||||||
|
* @since 1.3.0
|
||||||
|
* @version 2023.11.21
|
||||||
|
*/
|
||||||
|
case class FXApi (
|
||||||
|
code: Int,
|
||||||
|
message: String,
|
||||||
|
tweet: Option[FXTweet]
|
||||||
|
)
|
||||||
|
|
||||||
|
object FXApi {
|
||||||
|
|
||||||
|
object CirceADTs {
|
||||||
|
import io.circe.Decoder
|
||||||
|
import io.circe.generic.semiauto.deriveDecoder
|
||||||
|
implicit val decoderForAny: Decoder[Any] = _ => Right(None)
|
||||||
|
implicit val decoder_FXAuthor_website: Decoder[FXAuthor.websiteType] = deriveDecoder
|
||||||
|
implicit val decoder_FXAuthor: Decoder[FXAuthor] = deriveDecoder
|
||||||
|
implicit val decoder_FXExternalMedia: Decoder[FXExternalMedia] = deriveDecoder
|
||||||
|
implicit val decoder_FXMosaicPhoto_formats: Decoder[FXMosaicPhoto.formatsType] = deriveDecoder
|
||||||
|
implicit val decoder_FXMosaicPhoto: Decoder[FXMosaicPhoto] = deriveDecoder
|
||||||
|
implicit val decoder_FXPhoto: Decoder[FXPhoto] = deriveDecoder
|
||||||
|
implicit val decoder_FXVideo: Decoder[FXVideo] = deriveDecoder
|
||||||
|
implicit val decoder_FXPoolChoice: Decoder[FXPoolChoice] = deriveDecoder
|
||||||
|
implicit val decoder_FXPool: Decoder[FXPool] = deriveDecoder
|
||||||
|
implicit val decoder_FXTranslate: Decoder[FXTranslate] = deriveDecoder
|
||||||
|
implicit val decoder_FXTweet_media: Decoder[FXTweet.mediaType] = deriveDecoder
|
||||||
|
implicit val decoder_FXTweet: Decoder[FXTweet] = deriveDecoder
|
||||||
|
implicit val decoder_FXApi: Decoder[FXApi] = deriveDecoder
|
||||||
|
}
|
||||||
|
|
||||||
|
object Fetch {
|
||||||
|
|
||||||
|
import io.circe.parser
|
||||||
|
import CirceADTs.*
|
||||||
|
import sttp.client3.*
|
||||||
|
import sttp.client3.okhttp.OkHttpSyncBackend
|
||||||
|
|
||||||
|
val uri_base = uri"https://api.fxtwitter.com/"
|
||||||
|
/** Endpoint URI of [[https://github.com/FixTweet/FixTweet/wiki/Status-Fetch-API FixTweet Status Fetch API]]. */
|
||||||
|
val uri_status =
|
||||||
|
(screen_name: Option[String], id: String, translate_to: Option[String]) =>
|
||||||
|
uri"$uri_base/$screen_name/status/$id/$translate_to"
|
||||||
|
|
||||||
|
private val httpClient = OkHttpSyncBackend()
|
||||||
|
|
||||||
|
/** Get tweet data from [[uri_status FixTweet Status Fetch API]].
|
||||||
|
*
|
||||||
|
* This method uses [[SttpPublic.Headers.UserAgent.MORNY_CURRENT Morny HTTP User-Agent]]
|
||||||
|
*
|
||||||
|
* @param screen_name The screen name (@ handle) (aka. user id) of the
|
||||||
|
* tweet, which is ignored.
|
||||||
|
* @param id The ID of the status (tweet)
|
||||||
|
* @param translate_to 2 letter ISO language code of the language you
|
||||||
|
* want to translate the tweet into.
|
||||||
|
* @throws SttpClientException When HTTP Request fails due to network
|
||||||
|
* or else HTTP client related problem.
|
||||||
|
* @throws ParsingFailure When the response from API is not a regular JSON
|
||||||
|
* so cannot be parsed. It mostly due to some problem
|
||||||
|
* or breaking changes from the API serverside.
|
||||||
|
* @throws DecodingFailure When cannot decode the API response to a [[FXApi]]
|
||||||
|
* object. It might be some wrong with the [[FXApi]]
|
||||||
|
* model, or the remote API spec changes.
|
||||||
|
* @return a [[FXApi]] response object, with [[200]] or any other response code.
|
||||||
|
*/
|
||||||
|
@throws[SttpClientException|ParsingFailure|DecodingFailure]
|
||||||
|
def status (screen_name: Option[String], id: String, translate_to: Option[String] = None): FXApi =
|
||||||
|
val get = mornyBasicRequest
|
||||||
|
.get(uri_status(screen_name, id, translate_to))
|
||||||
|
.response(asString)
|
||||||
|
.send(httpClient)
|
||||||
|
val body = get.body match
|
||||||
|
case Left(error) => error
|
||||||
|
case Right(success) => success
|
||||||
|
parser.parse(body)
|
||||||
|
.toTry.get
|
||||||
|
.as[FXApi]
|
||||||
|
.toTry.get
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,33 @@
|
|||||||
|
package cc.sukazyo.cono.morny.extra.twitter
|
||||||
|
|
||||||
|
/** Information about the author of a tweet.
|
||||||
|
*
|
||||||
|
* @param name Name of the user, set on their profile
|
||||||
|
* @param screen_name Screen name or @ handle of the user.
|
||||||
|
* @param avatar_url URL for the user's avatar (profile picture)
|
||||||
|
* @param avatar_color Palette color corresponding to the user's avatar (profile picture). Value is a hex, including `#`.
|
||||||
|
* @param banner_url URL for the banner of the user
|
||||||
|
*/
|
||||||
|
case class FXAuthor (
|
||||||
|
name: String,
|
||||||
|
url: String,
|
||||||
|
screen_name: String,
|
||||||
|
avatar_url: Option[String],
|
||||||
|
avatar_color: Option[String],
|
||||||
|
banner_url: Option[String],
|
||||||
|
description: String, // todo
|
||||||
|
location: String, // todo
|
||||||
|
website: Option[FXAuthor.websiteType], // todo
|
||||||
|
followers: Int, // todo
|
||||||
|
following: Int, // todo
|
||||||
|
joined: String, // todo
|
||||||
|
likes: Int, // todo
|
||||||
|
tweets: Int // todo
|
||||||
|
)
|
||||||
|
|
||||||
|
object FXAuthor {
|
||||||
|
case class websiteType (
|
||||||
|
url: String,
|
||||||
|
display_url: String
|
||||||
|
)
|
||||||
|
}
|
@ -0,0 +1,17 @@
|
|||||||
|
package cc.sukazyo.cono.morny.extra.twitter
|
||||||
|
|
||||||
|
/** Data for external media, currently only video.
|
||||||
|
*
|
||||||
|
* @param `type` Embed type, currently always `video`
|
||||||
|
* @param url Video URL
|
||||||
|
* @param height Video height in pixels
|
||||||
|
* @param width Video width in pixels
|
||||||
|
* @param duration Video duration in seconds
|
||||||
|
*/
|
||||||
|
case class FXExternalMedia (
|
||||||
|
`type`: String,
|
||||||
|
url: String,
|
||||||
|
height: Int,
|
||||||
|
width: Int,
|
||||||
|
duration: Int
|
||||||
|
)
|
@ -0,0 +1,24 @@
|
|||||||
|
package cc.sukazyo.cono.morny.extra.twitter
|
||||||
|
|
||||||
|
import cc.sukazyo.cono.morny.extra.twitter.FXMosaicPhoto.formatsType
|
||||||
|
|
||||||
|
/** Data for the mosaic service, which stitches photos together
|
||||||
|
*
|
||||||
|
* @param `type` This can help compare items in a pool of media
|
||||||
|
* @param formats Pool of formats, only `jpeg` and `webp` are returned currently
|
||||||
|
*/
|
||||||
|
case class FXMosaicPhoto (
|
||||||
|
`type`: "mosaic_photo",
|
||||||
|
formats: formatsType
|
||||||
|
)
|
||||||
|
|
||||||
|
object FXMosaicPhoto {
|
||||||
|
/** Pool of formats, only `jpeg` and `webp` are returned currently.
|
||||||
|
* @param webp URL for webp resource
|
||||||
|
* @param jpeg URL for jpeg resource
|
||||||
|
*/
|
||||||
|
case class formatsType (
|
||||||
|
webp: String,
|
||||||
|
jpeg: String
|
||||||
|
)
|
||||||
|
}
|
@ -0,0 +1,16 @@
|
|||||||
|
package cc.sukazyo.cono.morny.extra.twitter
|
||||||
|
|
||||||
|
/** This can help compare items in a pool of media
|
||||||
|
*
|
||||||
|
* @param `type` This can help compare items in a pool of media
|
||||||
|
* @param url URL of the photo
|
||||||
|
* @param width Width of the photo, in pixels
|
||||||
|
* @param height Height of the photo, in pixels
|
||||||
|
*/
|
||||||
|
case class FXPhoto (
|
||||||
|
`type`: "photo",
|
||||||
|
url: String,
|
||||||
|
width: Int,
|
||||||
|
height: Int,
|
||||||
|
altText: String // todo
|
||||||
|
)
|
@ -0,0 +1,15 @@
|
|||||||
|
package cc.sukazyo.cono.morny.extra.twitter
|
||||||
|
|
||||||
|
/** Data for a poll on a given Tweet.
|
||||||
|
*
|
||||||
|
* @param choices Array of the poll choices
|
||||||
|
* @param total_votes Total votes in poll
|
||||||
|
* @param ends_at Date of which the poll ends
|
||||||
|
* @param time_left_en Time remaining counter in English (i.e. **9 hours left**)
|
||||||
|
*/
|
||||||
|
case class FXPool (
|
||||||
|
choices: List[FXPoolChoice],
|
||||||
|
total_votes: Int,
|
||||||
|
ends_at: String,
|
||||||
|
time_left_en: String
|
||||||
|
)
|
@ -0,0 +1,13 @@
|
|||||||
|
package cc.sukazyo.cono.morny.extra.twitter
|
||||||
|
|
||||||
|
/** Data for a single choice in a poll
|
||||||
|
*
|
||||||
|
* @param label What this choice in the poll is called
|
||||||
|
* @param count How many people voted in this poll
|
||||||
|
* @param percentage Percentage of total people who voted for this option (0 - 100, rounded to nearest tenth)
|
||||||
|
*/
|
||||||
|
case class FXPoolChoice (
|
||||||
|
label: String,
|
||||||
|
count: Int,
|
||||||
|
percentage: Int
|
||||||
|
)
|
@ -0,0 +1,13 @@
|
|||||||
|
package cc.sukazyo.cono.morny.extra.twitter
|
||||||
|
|
||||||
|
/** Information about a requested translation for a Tweet, when asked.
|
||||||
|
*
|
||||||
|
* @param text Translated Tweet text
|
||||||
|
* @param source_lang 2-letter ISO language code of source language
|
||||||
|
* @param target_lang 2-letter ISO language code of target language
|
||||||
|
*/
|
||||||
|
case class FXTranslate (
|
||||||
|
text: String,
|
||||||
|
source_lang: String,
|
||||||
|
target_lang: String
|
||||||
|
)
|
@ -0,0 +1,90 @@
|
|||||||
|
package cc.sukazyo.cono.morny.extra.twitter
|
||||||
|
|
||||||
|
import cc.sukazyo.cono.morny.extra.twitter.FXTweet.mediaType
|
||||||
|
import cc.sukazyo.cono.morny.util.EpochDateTime.EpochSeconds
|
||||||
|
|
||||||
|
/** The container of all the information for a Tweet.
|
||||||
|
*
|
||||||
|
* @param id Status (Tweet) ID
|
||||||
|
* @param url Link to original Tweet
|
||||||
|
* @param text Text of Tweet
|
||||||
|
* @param created_at Date/Time in UTC when the Tweet was created
|
||||||
|
* @param created_timestamp Date/Time in UTC when the Tweet was created
|
||||||
|
* @param color Dominant color pulled from either Tweet media or from the author's profile picture.
|
||||||
|
* @param lang Language that Twitter detects a Tweet is. May be null is unknown.
|
||||||
|
* @param replying_to Screen name of person being replied to, or null
|
||||||
|
* @param replying_to_status Tweet ID snowflake being replied to, or null
|
||||||
|
* @param twitter_card Corresponds to proper embed container for Tweet, which is used by
|
||||||
|
* FixTweet for our official embeds.<br>
|
||||||
|
* Notice that this should be of type [["tweet"|"summary"|"summary_large_image"|"player"]]
|
||||||
|
* but due to circe parser does not support it well so alternative
|
||||||
|
* [[String]] type is used.
|
||||||
|
* @param author Author of the tweet
|
||||||
|
* @param source Tweet source (i.e. Twitter for iPhone)
|
||||||
|
* @param likes Like count
|
||||||
|
* @param retweets Retweet count
|
||||||
|
* @param replies Reply count
|
||||||
|
* @param views View count, returns null if view count is not available (i.e. older Tweets)
|
||||||
|
* @param quote Nested Tweet corresponding to the tweet which this tweet is quoting, if applicable
|
||||||
|
* @param pool Poll attached to Tweet
|
||||||
|
* @param translation Translation results, only provided if explicitly asked
|
||||||
|
* @param media Containing object containing references to photos, videos, or external media
|
||||||
|
*/
|
||||||
|
case class FXTweet (
|
||||||
|
|
||||||
|
///====================
|
||||||
|
/// Core
|
||||||
|
///====================
|
||||||
|
|
||||||
|
id: String,
|
||||||
|
url: String,
|
||||||
|
text: String,
|
||||||
|
created_at: String,
|
||||||
|
created_timestamp: EpochSeconds,
|
||||||
|
is_note_tweet: Boolean, // todo
|
||||||
|
possibly_sensitive: Option[Boolean], // todo
|
||||||
|
color: Option[String],
|
||||||
|
lang: Option[String],
|
||||||
|
replying_to: Option[String],
|
||||||
|
replying_to_status: Option[String],
|
||||||
|
// twitter_card: "tweet"|"summary"|"summary_large_image"|"player",
|
||||||
|
twitter_card: Option[String],
|
||||||
|
author: FXAuthor,
|
||||||
|
source: String,
|
||||||
|
|
||||||
|
///====================
|
||||||
|
/// Interaction counts
|
||||||
|
///====================
|
||||||
|
|
||||||
|
likes: Int,
|
||||||
|
retweets: Int,
|
||||||
|
replies: Int,
|
||||||
|
views: Option[Int],
|
||||||
|
|
||||||
|
///====================
|
||||||
|
/// Embeds
|
||||||
|
///====================
|
||||||
|
|
||||||
|
quote: Option[FXTweet],
|
||||||
|
pool: Option[FXPool],
|
||||||
|
translation: Option[FXTranslate],
|
||||||
|
media: Option[mediaType]
|
||||||
|
|
||||||
|
)
|
||||||
|
|
||||||
|
object FXTweet {
|
||||||
|
/** Containing object containing references to photos, videos, or external media.
|
||||||
|
*
|
||||||
|
* @param external Refers to external media, such as YouTube embeds
|
||||||
|
* @param photos An Array of photos from a Tweet
|
||||||
|
* @param videos An Array of videos from a Tweet
|
||||||
|
* @param mosaic Corresponding Mosaic information for a Tweet
|
||||||
|
*/
|
||||||
|
case class mediaType (
|
||||||
|
all: Option[List[Any]], // todo
|
||||||
|
external: Option[FXExternalMedia],
|
||||||
|
photos: Option[List[FXPhoto]],
|
||||||
|
videos: Option[List[FXVideo]],
|
||||||
|
mosaic: Option[FXMosaicPhoto]
|
||||||
|
)
|
||||||
|
}
|
@ -0,0 +1,21 @@
|
|||||||
|
package cc.sukazyo.cono.morny.extra.twitter
|
||||||
|
|
||||||
|
/** Data for a Tweet's video
|
||||||
|
*
|
||||||
|
* @param `type` Returns video if video, or gif if gif. Note that on Twitter, all GIFs are MP4s.
|
||||||
|
* @param url URL corresponding to the video file
|
||||||
|
* @param thumbnail_url URL corresponding to the thumbnail for the video
|
||||||
|
* @param width Width of the video, in pixels
|
||||||
|
* @param height Height of the video, in pixels
|
||||||
|
* @param format Video format, usually `video/mp4`
|
||||||
|
*/
|
||||||
|
case class FXVideo (
|
||||||
|
// `type`: "video"|"gif",
|
||||||
|
`type`: String,
|
||||||
|
url: String,
|
||||||
|
thumbnail_url: String,
|
||||||
|
width: Int,
|
||||||
|
height: Int,
|
||||||
|
duration: Float, // todo
|
||||||
|
format: String
|
||||||
|
)
|
@ -0,0 +1,81 @@
|
|||||||
|
package cc.sukazyo.cono.morny.extra
|
||||||
|
|
||||||
|
import scala.util.matching.Regex
|
||||||
|
|
||||||
|
package object twitter {
|
||||||
|
|
||||||
|
private val REGEX_TWEET_URL: Regex = "(?:https?://)?((?:(?:(?:c\\.)?vx|fx|www\\.)?twitter|(?:www\\.|fixup|fixv)?x)\\.com)/((\\w+)/status/(\\d+)(?:/photo/(\\d+))?)/?(?:\\?(\\S+))?"r
|
||||||
|
|
||||||
|
/** Messages that can contains on a tweet url.
|
||||||
|
*
|
||||||
|
* A tweet url is like `https://twitter.com/pj_sekai/status/1726526899982352557?s=20`
|
||||||
|
* which can be found in address bar of tweet page or tweet's share link.
|
||||||
|
*
|
||||||
|
* @param domain Domain of the tweet url. Normally `twitter.com` or `x.com`
|
||||||
|
* (can be with `www.` or without). But [[parseTweetUrl]] also
|
||||||
|
* supports to parse some third-party tweet share url domain
|
||||||
|
* includes `fx.twitter.com`, `vxtwitter.com`(or `c.vxtwitter.com`
|
||||||
|
* though it have been deprecated), or `fixupx.com`.
|
||||||
|
* @param statusPath Full path of the status. It should be like
|
||||||
|
* `$screenName/status/$statusId`, with or without photo param
|
||||||
|
* like `/photo/$subPhotoId`. It does not contains tracking
|
||||||
|
* or any else params.
|
||||||
|
* @param screenName Screen name of the tweet author, aka. author's user id.
|
||||||
|
* For most case this section is useless in processing at
|
||||||
|
* the backend (because [[statusId]] along is accurate enough)
|
||||||
|
* so it may not be right, but it should always exists.
|
||||||
|
* @param statusId Unique ID of the status. It is unique in whole Twitter globe.
|
||||||
|
* Should be a number.
|
||||||
|
* @param subPhotoId photo id or serial number in the status. Unique in the status
|
||||||
|
* globe, only exists when specific a photo in the status. It should
|
||||||
|
* be a number of 0~3 (because twitter supports 4 image at most in
|
||||||
|
* one tweet).
|
||||||
|
* @param trackingParam All of encoded url params. Normally no data here is something
|
||||||
|
* important.
|
||||||
|
*/
|
||||||
|
case class TweetUrlInformation (
|
||||||
|
domain: String,
|
||||||
|
statusPath: String,
|
||||||
|
screenName: String,
|
||||||
|
statusId: String,
|
||||||
|
subPhotoId: Option[String],
|
||||||
|
trackingParam: Option[String]
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Parse a url to [[TweetUrlInformation]] for future processing.
|
||||||
|
*
|
||||||
|
* Supports following url:
|
||||||
|
*
|
||||||
|
* - `twitter.com` or `www.twitter.com`
|
||||||
|
* - `x.com` or `www.x.com`
|
||||||
|
* - `fxtwitter.com` or `fixupx.com`
|
||||||
|
* - `vxtwitter.com` or `c.vxtwitter.com` or `fixvx.com`
|
||||||
|
* - should be the path of `/:screenName/status/:id`
|
||||||
|
* - can contains `./photo/:photoId`
|
||||||
|
* - url param non-sensitive
|
||||||
|
* - http or https non-sensitive
|
||||||
|
*
|
||||||
|
* @param url a supported tweet URL or not.
|
||||||
|
* @return [[Option]] with [[TweetUrlInformation]] if the input url is a supported
|
||||||
|
* tweet url, or [[None]] if it's not.
|
||||||
|
*/
|
||||||
|
def parseTweetUrl (url: String): Option[TweetUrlInformation] =
|
||||||
|
url match
|
||||||
|
case REGEX_TWEET_URL(_1, _2, _3, _4, _5, _6) =>
|
||||||
|
Some(TweetUrlInformation(
|
||||||
|
_1, _2, _3, _4,
|
||||||
|
Option(_5),
|
||||||
|
Option(_6)
|
||||||
|
))
|
||||||
|
case _ => None
|
||||||
|
|
||||||
|
def guessTweetUrl (text: String): List[TweetUrlInformation] =
|
||||||
|
REGEX_TWEET_URL.findAllMatchIn(text).map(f => {
|
||||||
|
TweetUrlInformation(
|
||||||
|
f.group(1), f.group(2), f.group(3), f.group(4),
|
||||||
|
Option(f.group(5)),
|
||||||
|
Option(f.group(6))
|
||||||
|
)
|
||||||
|
}).toList
|
||||||
|
|
||||||
|
}
|
66
src/main/scala/cc/sukazyo/cono/morny/extra/weibo/MApi.scala
Normal file
66
src/main/scala/cc/sukazyo/cono/morny/extra/weibo/MApi.scala
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
package cc.sukazyo.cono.morny.extra.weibo
|
||||||
|
|
||||||
|
case class MApi [D] (
|
||||||
|
ok: Int,
|
||||||
|
data: D
|
||||||
|
)
|
||||||
|
|
||||||
|
object MApi {
|
||||||
|
|
||||||
|
object CirceADTs {
|
||||||
|
import io.circe.Decoder
|
||||||
|
import io.circe.generic.semiauto.deriveDecoder
|
||||||
|
given Decoder[MUser] = deriveDecoder
|
||||||
|
given given_Decoder_largeType_getType: Decoder[MPic.largeType.geoType] = deriveDecoder
|
||||||
|
given Decoder[MPic.largeType] = deriveDecoder
|
||||||
|
given Decoder[MPic.geoType] = deriveDecoder
|
||||||
|
given Decoder[MPic] = deriveDecoder
|
||||||
|
given Decoder[MStatus] = deriveDecoder
|
||||||
|
given Decoder[MApi[MStatus]] = deriveDecoder
|
||||||
|
}
|
||||||
|
|
||||||
|
object Fetch {
|
||||||
|
|
||||||
|
import cc.sukazyo.cono.morny.util.SttpPublic
|
||||||
|
import cc.sukazyo.cono.morny.util.SttpPublic.mornyBasicRequest
|
||||||
|
import io.circe.{parser, DecodingFailure, ParsingFailure}
|
||||||
|
import sttp.client3.{HttpError, SttpClientException, UriContext}
|
||||||
|
import sttp.client3.okhttp.OkHttpSyncBackend
|
||||||
|
|
||||||
|
val uri_base = uri"https://m.weibo.cn/"
|
||||||
|
val uri_statuses_show =
|
||||||
|
(id: String) => uri"$uri_base/statuses/show?id=$id"
|
||||||
|
|
||||||
|
private val httpClient = OkHttpSyncBackend()
|
||||||
|
|
||||||
|
@throws[HttpError[_]|SttpClientException|ParsingFailure|DecodingFailure]
|
||||||
|
def statuses_show (id: String): MApi[MStatus] =
|
||||||
|
import sttp.client3.asString
|
||||||
|
import MApi.CirceADTs.given
|
||||||
|
val response = mornyBasicRequest
|
||||||
|
.get(uri_statuses_show(id))
|
||||||
|
.response(asString.getRight)
|
||||||
|
.send(httpClient)
|
||||||
|
parser.parse(response.body)
|
||||||
|
.toTry.get
|
||||||
|
.as[MApi[MStatus]]
|
||||||
|
.toTry.get
|
||||||
|
|
||||||
|
@throws[HttpError[_] | SttpClientException | ParsingFailure | DecodingFailure]
|
||||||
|
def pic (picUrl: String): Array[Byte] =
|
||||||
|
import sttp.client3.*
|
||||||
|
import sttp.model.{MediaType, Uri}
|
||||||
|
mornyBasicRequest
|
||||||
|
.acceptEncoding(MediaType.ImageJpeg.toString)
|
||||||
|
.get(Uri.unsafeParse(picUrl))
|
||||||
|
.response(asByteArray.getRight)
|
||||||
|
.send(httpClient)
|
||||||
|
.body
|
||||||
|
|
||||||
|
// @throws[HttpError[_] | SttpClientException | ParsingFailure | DecodingFailure]
|
||||||
|
// def pic (info: PicUrl): Array[Byte] =
|
||||||
|
// pic(info.toUrl)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
33
src/main/scala/cc/sukazyo/cono/morny/extra/weibo/MPic.scala
Normal file
33
src/main/scala/cc/sukazyo/cono/morny/extra/weibo/MPic.scala
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
package cc.sukazyo.cono.morny.extra.weibo
|
||||||
|
|
||||||
|
case class MPic (
|
||||||
|
pid: String,
|
||||||
|
url: String,
|
||||||
|
size: String,
|
||||||
|
geo: MPic.geoType,
|
||||||
|
large: MPic.largeType
|
||||||
|
)
|
||||||
|
|
||||||
|
object MPic {
|
||||||
|
|
||||||
|
case class geoType (
|
||||||
|
// width: Int,
|
||||||
|
// height: Int,
|
||||||
|
croped: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
case class largeType (
|
||||||
|
size: String,
|
||||||
|
url: String,
|
||||||
|
geo: largeType.geoType
|
||||||
|
)
|
||||||
|
|
||||||
|
object largeType {
|
||||||
|
case class geoType (
|
||||||
|
// width: String,
|
||||||
|
// height: String,
|
||||||
|
croped: Boolean
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,92 @@
|
|||||||
|
package cc.sukazyo.cono.morny.extra.weibo
|
||||||
|
|
||||||
|
case class MStatus (
|
||||||
|
|
||||||
|
id: String,
|
||||||
|
mid: String,
|
||||||
|
bid: String,
|
||||||
|
|
||||||
|
created_at: String,
|
||||||
|
text: String,
|
||||||
|
raw_text: Option[String],
|
||||||
|
|
||||||
|
user: MUser,
|
||||||
|
|
||||||
|
retweeted_status: Option[MStatus],
|
||||||
|
|
||||||
|
pic_ids: List[String],
|
||||||
|
pics: Option[List[MPic]],
|
||||||
|
thumbnail_pic: Option[String],
|
||||||
|
bmiddle_pic: Option[String],
|
||||||
|
original_pic: Option[String],
|
||||||
|
|
||||||
|
// visible: Nothing,
|
||||||
|
// created_at: String,
|
||||||
|
// id: String,
|
||||||
|
// mid: String,
|
||||||
|
// bid: String,
|
||||||
|
// can_edit: Boolean,
|
||||||
|
// show_additional_indication: Int,
|
||||||
|
// text: String,
|
||||||
|
// textLength: Option[Int],
|
||||||
|
// source: String,
|
||||||
|
// favorited: Boolean,
|
||||||
|
// pic_ids: List[String],
|
||||||
|
// pic_focus_point: Option[List[Nothing]],
|
||||||
|
// falls_pic_focus_point: Option[List[Nothing]],
|
||||||
|
// pic_rectangle_object: Option[List[Nothing]],
|
||||||
|
// pic_flag: Option[Int],
|
||||||
|
// thumbnail_pic: Option[String],
|
||||||
|
// bmiddle_pic: Option[String],
|
||||||
|
// original_pic: Option[String],
|
||||||
|
// is_paid: Boolean,
|
||||||
|
// mblog_vip_type: Int,
|
||||||
|
// user: Nothing,
|
||||||
|
// picStatus: Option[String],
|
||||||
|
// retweeted_status: Option[Nothing],
|
||||||
|
// reposts_count: Int,
|
||||||
|
// comments_count: Int,
|
||||||
|
// reprint_cmt_count: Int,
|
||||||
|
// attitudes_count: Int,
|
||||||
|
// pending_approval_count: Int,
|
||||||
|
// isLongText: Boolean,
|
||||||
|
// show_mlevel: Int,
|
||||||
|
// topic_id: Option[String],
|
||||||
|
// sync_mblog: Option[Boolean],
|
||||||
|
// is_imported_topic: Option[Boolean],
|
||||||
|
// darwin_tags: List[Nothing],
|
||||||
|
// ad_marked: Boolean,
|
||||||
|
// mblogtype: Int,
|
||||||
|
// item_category: String,
|
||||||
|
// rid: String,
|
||||||
|
// number_display_strategy: Nothing,
|
||||||
|
// content_auth: Int,
|
||||||
|
// safe_tags: Option[Int],
|
||||||
|
// comment_manage_info: Nothing,
|
||||||
|
// repost_type: Option[Int],
|
||||||
|
// pic_num: Int,
|
||||||
|
// jump_type: Option[Int],
|
||||||
|
// hot_page: Nothing,
|
||||||
|
// new_comment_style: Int,
|
||||||
|
// ab_switcher: Int,
|
||||||
|
// mlevel: Int,
|
||||||
|
// region_name: String,
|
||||||
|
// region_opt: 1,
|
||||||
|
// page_info: Option[Nothing],
|
||||||
|
// pics: Option[List[Nothing]],
|
||||||
|
// raw_text: Option[String],
|
||||||
|
// buttons: List[Nothing],
|
||||||
|
// status_title: Option[String],
|
||||||
|
// ok: Int,
|
||||||
|
|
||||||
|
|
||||||
|
// pid: Long,
|
||||||
|
// pidstr: String,
|
||||||
|
// pic_types: String,
|
||||||
|
// alchemy_params: Nothing,
|
||||||
|
// ad_state: Int,
|
||||||
|
// cardid: String,
|
||||||
|
// hide_flag: Int,
|
||||||
|
// mark: String,
|
||||||
|
// more_info_type: Int,
|
||||||
|
)
|
13
src/main/scala/cc/sukazyo/cono/morny/extra/weibo/MUser.scala
Normal file
13
src/main/scala/cc/sukazyo/cono/morny/extra/weibo/MUser.scala
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
package cc.sukazyo.cono.morny.extra.weibo
|
||||||
|
|
||||||
|
case class MUser (
|
||||||
|
|
||||||
|
id: Long,
|
||||||
|
screen_name: String,
|
||||||
|
profile_url: String,
|
||||||
|
profile_image_url: Option[String],
|
||||||
|
avatar_hd: Option[String],
|
||||||
|
description: Option[String],
|
||||||
|
cover_image_phone: Option[String],
|
||||||
|
|
||||||
|
)
|
@ -0,0 +1,45 @@
|
|||||||
|
package cc.sukazyo.cono.morny.extra
|
||||||
|
|
||||||
|
package object weibo {
|
||||||
|
|
||||||
|
/** Information in weibo status url.
|
||||||
|
*
|
||||||
|
* @param uid Status owner's user id. should be a number.
|
||||||
|
* @param id Status id. Should be unique in the whole weibo.com
|
||||||
|
* globe. Maybe a number format mid, or a base58-like
|
||||||
|
* bid.
|
||||||
|
*/
|
||||||
|
case class StatusUrlInfo (
|
||||||
|
uid: String,
|
||||||
|
id: String
|
||||||
|
)
|
||||||
|
|
||||||
|
// case class PicUrl (
|
||||||
|
// cdn: String,
|
||||||
|
// mode: String,
|
||||||
|
// pid: String
|
||||||
|
// ) {
|
||||||
|
// def toUrl: String =
|
||||||
|
// s"https://$cdn.singimg.cn/$mode/$pid.jpg"
|
||||||
|
// }
|
||||||
|
|
||||||
|
private val REGEX_WEIBO_STATUS_URL = "(?:https?://)?((?:www\\.|m.)?weibo\\.(?:com|cn))/(\\d+)/([0-9a-zA-Z]+)/?(?:\\?(\\S+))?"r
|
||||||
|
|
||||||
|
def parseWeiboStatusUrl (url: String): Option[StatusUrlInfo] =
|
||||||
|
url match
|
||||||
|
case REGEX_WEIBO_STATUS_URL(_, uid, id, _) => Some(StatusUrlInfo(uid, id))
|
||||||
|
case _ => None
|
||||||
|
|
||||||
|
def guessWeiboStatusUrl (text: String): List[StatusUrlInfo] =
|
||||||
|
REGEX_WEIBO_STATUS_URL.findAllMatchIn(text).map(matches => {
|
||||||
|
StatusUrlInfo(matches.group(2), matches.group(3))
|
||||||
|
}).toList
|
||||||
|
|
||||||
|
def genWeiboStatusUrl (url: StatusUrlInfo): String =
|
||||||
|
s"https://weibo.com/${url.uid}/${url.id}"
|
||||||
|
|
||||||
|
// def randomPicCdn: String =
|
||||||
|
// import scala.util.Random
|
||||||
|
// s"wx${Random.nextInt(4)+1}"
|
||||||
|
|
||||||
|
}
|
@ -5,9 +5,11 @@ import java.time.format.DateTimeFormatter
|
|||||||
|
|
||||||
object EpochDateTime {
|
object EpochDateTime {
|
||||||
|
|
||||||
|
/** The UNIX Epoch Time in milliseconds.
|
||||||
|
*
|
||||||
|
* aka. Milliseconds since 00:00:00 UTC on Thursday, 1 January 1970.
|
||||||
|
*/
|
||||||
type EpochMillis = Long
|
type EpochMillis = Long
|
||||||
type DurationMillis = Long
|
|
||||||
|
|
||||||
object EpochMillis:
|
object EpochMillis:
|
||||||
/** convert a localtime with timezone to epoch milliseconds
|
/** convert a localtime with timezone to epoch milliseconds
|
||||||
*
|
*
|
||||||
@ -27,4 +29,40 @@ object EpochDateTime {
|
|||||||
time_zone match
|
time_zone match
|
||||||
case (time, zone) => apply(time, zone)
|
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
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,9 @@
|
|||||||
package cc.sukazyo.cono.morny.util
|
package cc.sukazyo.cono.morny.util
|
||||||
|
|
||||||
|
import cc.sukazyo.cono.morny.MornySystem
|
||||||
|
import sttp.client3.basicRequest
|
||||||
|
import sttp.model.Header
|
||||||
|
|
||||||
object SttpPublic {
|
object SttpPublic {
|
||||||
|
|
||||||
object Schemes {
|
object Schemes {
|
||||||
@ -7,4 +11,20 @@ object SttpPublic {
|
|||||||
val HTTPS = "https"
|
val HTTPS = "https"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
object Headers {
|
||||||
|
|
||||||
|
object UserAgent {
|
||||||
|
|
||||||
|
private val key = "User-Agent"
|
||||||
|
|
||||||
|
val MORNY_CURRENT: Header = Header(key, s"MornyCoeur / ${MornySystem.VERSION}")
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
val mornyBasicRequest =
|
||||||
|
basicRequest
|
||||||
|
.header(Headers.UserAgent.MORNY_CURRENT, true)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -16,4 +16,9 @@ object UseMath {
|
|||||||
def ** (other: Int): Double = Math.pow(self, other)
|
def ** (other: Int): Double = Math.pow(self, other)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension (base: Int) {
|
||||||
|
def percentageOf (another: Int): Int =
|
||||||
|
Math.round((another.toDouble/base)*100).toInt
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,46 @@
|
|||||||
|
package cc.sukazyo.cono.morny.util.schedule
|
||||||
|
|
||||||
|
import cc.sukazyo.cono.morny.util.EpochDateTime.EpochMillis
|
||||||
|
import com.cronutils.model.time.ExecutionTime
|
||||||
|
import com.cronutils.model.Cron
|
||||||
|
|
||||||
|
import java.time.{Instant, ZonedDateTime, ZoneId}
|
||||||
|
import scala.jdk.OptionConverters.*
|
||||||
|
|
||||||
|
trait CronTask extends RoutineTask {
|
||||||
|
|
||||||
|
private lazy val cronCalc = ExecutionTime.forCron(cron)
|
||||||
|
|
||||||
|
def cron: Cron
|
||||||
|
|
||||||
|
def zone: ZoneId
|
||||||
|
|
||||||
|
override def firstRoutineTimeMillis: EpochMillis =
|
||||||
|
cronCalc.nextExecution(
|
||||||
|
ZonedDateTime.ofInstant(
|
||||||
|
Instant.now, zone
|
||||||
|
)
|
||||||
|
).get.toInstant.toEpochMilli
|
||||||
|
|
||||||
|
override def nextRoutineTimeMillis (previousRoutineScheduledTimeMillis: EpochMillis): EpochMillis | Null =
|
||||||
|
cronCalc.nextExecution(
|
||||||
|
ZonedDateTime.ofInstant(
|
||||||
|
Instant.ofEpochMilli(previousRoutineScheduledTimeMillis),
|
||||||
|
zone
|
||||||
|
)
|
||||||
|
).toScala match
|
||||||
|
case Some(time) => time.toInstant.toEpochMilli
|
||||||
|
case None => null
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
object CronTask {
|
||||||
|
|
||||||
|
def apply (_name: String, _cron: Cron, _zone: ZoneId, _main: =>Unit): CronTask =
|
||||||
|
new CronTask:
|
||||||
|
override def name: String = _name
|
||||||
|
override def cron: Cron = _cron
|
||||||
|
override def zone: ZoneId = _zone
|
||||||
|
override def main: Unit = _main
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,20 @@
|
|||||||
|
package cc.sukazyo.cono.morny.util.schedule
|
||||||
|
|
||||||
|
import cc.sukazyo.cono.morny.util.EpochDateTime.{DurationMillis, EpochMillis}
|
||||||
|
|
||||||
|
trait DelayedTask (
|
||||||
|
val delayedMillis: DurationMillis
|
||||||
|
) extends Task {
|
||||||
|
|
||||||
|
override val scheduledTimeMillis: EpochMillis = System.currentTimeMillis + delayedMillis
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
object DelayedTask {
|
||||||
|
|
||||||
|
def apply (_name: String, delayedMillis: DurationMillis, task: =>Unit): DelayedTask =
|
||||||
|
new DelayedTask (delayedMillis):
|
||||||
|
override val name: String = _name
|
||||||
|
override def main: Unit = task
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,27 @@
|
|||||||
|
package cc.sukazyo.cono.morny.util.schedule
|
||||||
|
|
||||||
|
import cc.sukazyo.cono.morny.util.EpochDateTime.{DurationMillis, EpochMillis}
|
||||||
|
|
||||||
|
trait IntervalTask extends RoutineTask {
|
||||||
|
|
||||||
|
def intervalMillis: DurationMillis
|
||||||
|
|
||||||
|
override def firstRoutineTimeMillis: EpochMillis =
|
||||||
|
System.currentTimeMillis() + intervalMillis
|
||||||
|
|
||||||
|
override def nextRoutineTimeMillis (
|
||||||
|
previousScheduledRoutineTimeMillis: EpochMillis
|
||||||
|
): EpochMillis|Null =
|
||||||
|
previousScheduledRoutineTimeMillis + intervalMillis
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
object IntervalTask {
|
||||||
|
|
||||||
|
def apply (_name: String, _intervalMillis: DurationMillis, task: =>Unit): IntervalTask =
|
||||||
|
new IntervalTask:
|
||||||
|
override def intervalMillis: DurationMillis = _intervalMillis
|
||||||
|
override def name: String = _name
|
||||||
|
override def main: Unit = task
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,28 @@
|
|||||||
|
package cc.sukazyo.cono.morny.util.schedule
|
||||||
|
|
||||||
|
import cc.sukazyo.cono.morny.util.EpochDateTime.{DurationMillis, EpochMillis}
|
||||||
|
|
||||||
|
trait IntervalWithTimesTask extends IntervalTask {
|
||||||
|
|
||||||
|
def times: Int
|
||||||
|
private var currentExecutedTimes = 1
|
||||||
|
|
||||||
|
override def nextRoutineTimeMillis (previousScheduledRoutineTimeMillis: EpochMillis): EpochMillis | Null =
|
||||||
|
if currentExecutedTimes >= times then
|
||||||
|
null
|
||||||
|
else
|
||||||
|
currentExecutedTimes = currentExecutedTimes + 1
|
||||||
|
super.nextRoutineTimeMillis(previousScheduledRoutineTimeMillis)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
object IntervalWithTimesTask {
|
||||||
|
|
||||||
|
def apply (_name: String, _intervalMillis: DurationMillis, _times: Int, task: =>Unit): IntervalWithTimesTask =
|
||||||
|
new IntervalWithTimesTask:
|
||||||
|
override def name: String = _name
|
||||||
|
override def times: Int = _times
|
||||||
|
override def intervalMillis: DurationMillis = _intervalMillis
|
||||||
|
override def main: Unit = task
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,51 @@
|
|||||||
|
package cc.sukazyo.cono.morny.util.schedule
|
||||||
|
|
||||||
|
import cc.sukazyo.cono.morny.util.EpochDateTime.EpochMillis
|
||||||
|
|
||||||
|
/** The task that can execute multiple times with custom routine function.
|
||||||
|
*
|
||||||
|
* When creating a Routine Task, the task's [[firstRoutineTimeMillis]] function
|
||||||
|
* will be called and the result value will be the first task scheduled time.
|
||||||
|
*
|
||||||
|
* After every execution complete and enter the post effect, the [[nextRoutineTimeMillis]]
|
||||||
|
* function will be called, then its value will be stored as the new task's
|
||||||
|
* scheduled time and re-scheduled by its scheduler.
|
||||||
|
*/
|
||||||
|
trait RoutineTask extends Task {
|
||||||
|
|
||||||
|
private[schedule] var currentScheduledTimeMillis: Option[EpochMillis] = None
|
||||||
|
|
||||||
|
/** Next running time of this task.
|
||||||
|
*
|
||||||
|
* Should be auto generated from [[firstRoutineTimeMillis]] when this method
|
||||||
|
* is called at first time, and then from [[nextRoutineTimeMillis]] for following
|
||||||
|
* routines controlled by [[Scheduler]].
|
||||||
|
*/
|
||||||
|
override def scheduledTimeMillis: EpochMillis =
|
||||||
|
currentScheduledTimeMillis match
|
||||||
|
case Some(time) => time
|
||||||
|
case None =>
|
||||||
|
currentScheduledTimeMillis = Some(firstRoutineTimeMillis)
|
||||||
|
currentScheduledTimeMillis.get
|
||||||
|
|
||||||
|
/** The task scheduled time at initial.
|
||||||
|
*
|
||||||
|
* In the default environment, this function will only be called once
|
||||||
|
* when the task object is just created.
|
||||||
|
*/
|
||||||
|
def firstRoutineTimeMillis: EpochMillis
|
||||||
|
|
||||||
|
/** The function to calculate the next scheduled time after previous task
|
||||||
|
* routine complete.
|
||||||
|
*
|
||||||
|
* This function will be called every time the task is done once, in the
|
||||||
|
* task runner thread and the post effect scope.
|
||||||
|
*
|
||||||
|
* @param previousRoutineScheduledTimeMillis The previous task routine's
|
||||||
|
* scheduled time.
|
||||||
|
* @return The next task routine's scheduled time, or [[null]] means end
|
||||||
|
* of the task.
|
||||||
|
*/
|
||||||
|
def nextRoutineTimeMillis (previousRoutineScheduledTimeMillis: EpochMillis): EpochMillis|Null
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,280 @@
|
|||||||
|
package cc.sukazyo.cono.morny.util.schedule
|
||||||
|
|
||||||
|
import cc.sukazyo.cono.morny.util.EpochDateTime.EpochMillis
|
||||||
|
|
||||||
|
import scala.annotation.targetName
|
||||||
|
import scala.collection.mutable
|
||||||
|
|
||||||
|
/** Stores some [[Task tasks]] and execute them at time defined in task.
|
||||||
|
*
|
||||||
|
* == Usage ==
|
||||||
|
*
|
||||||
|
* Start a new scheduler instance by create a new Scheduler object, and
|
||||||
|
* the scheduler runner will automatic start to run.
|
||||||
|
*
|
||||||
|
* Using [[Scheduler.++]] or [[Scheduler.schedule]] to add a [[Task]] to
|
||||||
|
* a Scheduler instance.
|
||||||
|
*
|
||||||
|
* If you want to remove a task, use [[Scheduler.%]] or [[Scheduler.cancel]].
|
||||||
|
* Removal task should be the same task object, but not just the same name.
|
||||||
|
*
|
||||||
|
* The scheduler will not automatic stop when the tasks is all done and the
|
||||||
|
* main thread is stopped. You can/should use [[stop]], [[waitForStop]],
|
||||||
|
* [[tagStopAtAllDone]], [[waitForStopAtAllDone]] to async or syncing stop
|
||||||
|
* the scheduler.
|
||||||
|
*
|
||||||
|
* == Implementation details ==
|
||||||
|
*
|
||||||
|
* Inside the Scheduler, the runner's implementation is very similar to
|
||||||
|
* java's [[java.util.Timer]]: There's a task queue sorted by [[Task.scheduledTimeMillis]]
|
||||||
|
* (which is the default order method implemented in [[Task]]), and a
|
||||||
|
* runner getting the most previous task in the queue, and sleep to that
|
||||||
|
* task's execution time.
|
||||||
|
*
|
||||||
|
* Every time the runner is executing a task, it will firstly set its thread name
|
||||||
|
* to [[Task.name]]. After running a task, if the task have some post-process
|
||||||
|
* method (like [[RoutineTask]] will do prepare for next routine), the runner's
|
||||||
|
* thread name will be set to <code>[[Task.name]]#post</code>. After all of
|
||||||
|
* that, the task is fully complete, and the runner's thread name will be
|
||||||
|
* reset to [[runnerName]].
|
||||||
|
*/
|
||||||
|
class Scheduler {
|
||||||
|
|
||||||
|
/** Status tag of this scheduler. */
|
||||||
|
//noinspection ScalaWeakerAccess
|
||||||
|
enum State:
|
||||||
|
/** The scheduler is on init stage, have not prepared for running tasks. */
|
||||||
|
case INIT
|
||||||
|
/** The scheduler is managing the task queue, processing the exit signal,
|
||||||
|
* and looking for the next running task. */
|
||||||
|
case PREPARE_RUN
|
||||||
|
/** The scheduler is infinitely waiting due to there's nothing in the task
|
||||||
|
* queue. */
|
||||||
|
case WAITING_EMPTY
|
||||||
|
/** The scheduler is waiting until the next task's running time. */
|
||||||
|
case WAITING
|
||||||
|
/** The scheduler is running a task in the runner. */
|
||||||
|
case RUNNING
|
||||||
|
/** The scheduler is executing a task's post effect. */
|
||||||
|
case RUNNING_POST
|
||||||
|
/** The scheduler have been stopped, will not process any more tasks. */
|
||||||
|
case END
|
||||||
|
|
||||||
|
private val taskList: mutable.TreeSet[Task] = mutable.TreeSet.empty
|
||||||
|
private var exitAtNextRoutine = false
|
||||||
|
private var waitForDone = false
|
||||||
|
// private var currentRunning: Task|Null = _
|
||||||
|
private var runtimeStatus = State.INIT
|
||||||
|
private val runtime: Thread = new Thread {
|
||||||
|
|
||||||
|
override def run (): Unit = {
|
||||||
|
def willExit: Boolean =
|
||||||
|
if exitAtNextRoutine then true
|
||||||
|
else if waitForDone then
|
||||||
|
taskList.synchronized:
|
||||||
|
if taskList.isEmpty then true
|
||||||
|
else false
|
||||||
|
else false
|
||||||
|
taskList.synchronized { while (!willExit) {
|
||||||
|
|
||||||
|
runtimeStatus = State.PREPARE_RUN
|
||||||
|
|
||||||
|
val nextMove: Task|EpochMillis|"None" =
|
||||||
|
taskList.headOption match
|
||||||
|
case Some(_readyToRun) if System.currentTimeMillis >= _readyToRun.scheduledTimeMillis =>
|
||||||
|
taskList -= _readyToRun
|
||||||
|
// currentRunning = _readyToRun
|
||||||
|
_readyToRun
|
||||||
|
case Some(_notReady) =>
|
||||||
|
_notReady.scheduledTimeMillis - System.currentTimeMillis
|
||||||
|
case None => "None"
|
||||||
|
|
||||||
|
nextMove match
|
||||||
|
case readyToRun: Task =>
|
||||||
|
|
||||||
|
runtimeStatus = State.RUNNING
|
||||||
|
this setName readyToRun.name
|
||||||
|
|
||||||
|
try {
|
||||||
|
readyToRun.main
|
||||||
|
} catch case _: (Exception | Error) => {}
|
||||||
|
|
||||||
|
runtimeStatus = State.RUNNING_POST
|
||||||
|
this setName s"${readyToRun.name}#post"
|
||||||
|
|
||||||
|
// this if is used for check if post effect need to be
|
||||||
|
// run. It is useless since the wait/notify changes.
|
||||||
|
if false then {}
|
||||||
|
else {
|
||||||
|
readyToRun match
|
||||||
|
case routine: RoutineTask =>
|
||||||
|
routine.nextRoutineTimeMillis(routine.currentScheduledTimeMillis.get) match
|
||||||
|
case next: EpochMillis =>
|
||||||
|
routine.currentScheduledTimeMillis = Some(next)
|
||||||
|
schedule(routine)
|
||||||
|
case _ =>
|
||||||
|
case _ =>
|
||||||
|
}
|
||||||
|
|
||||||
|
// currentRunning = null
|
||||||
|
this setName runnerName
|
||||||
|
|
||||||
|
case needToWaitMillis: EpochMillis =>
|
||||||
|
runtimeStatus = State.WAITING
|
||||||
|
try taskList.wait(needToWaitMillis)
|
||||||
|
catch case _: (InterruptedException|IllegalArgumentException) => {}
|
||||||
|
case _: "None" =>
|
||||||
|
runtimeStatus = State.WAITING_EMPTY
|
||||||
|
try taskList.wait()
|
||||||
|
catch case _: InterruptedException => {}
|
||||||
|
|
||||||
|
}}
|
||||||
|
runtimeStatus = State.END
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
runtime setName runnerName
|
||||||
|
runtime.start()
|
||||||
|
|
||||||
|
/** Name of the scheduler runner.
|
||||||
|
* Currently, same with the scheduler [[toString]]
|
||||||
|
*/
|
||||||
|
//noinspection ScalaWeakerAccess
|
||||||
|
def runnerName: String =
|
||||||
|
s"${this.getClass.getSimpleName}@${this.hashCode.toHexString}"
|
||||||
|
|
||||||
|
/** Add one task to scheduler task queue.
|
||||||
|
* @return this scheduler for chained call.
|
||||||
|
*/
|
||||||
|
@targetName("scheduleIt")
|
||||||
|
def ++ (task: Task): this.type =
|
||||||
|
schedule(task)
|
||||||
|
this
|
||||||
|
/** Add one task to scheduler task queue.
|
||||||
|
* @return [[true]] if the task is added.
|
||||||
|
*/
|
||||||
|
def schedule (task: Task): Boolean =
|
||||||
|
taskList.synchronized:
|
||||||
|
try taskList add task
|
||||||
|
finally taskList.notifyAll()
|
||||||
|
|
||||||
|
/** Remove the task from scheduler task queue.
|
||||||
|
*
|
||||||
|
* If the removal task is running, the current run will be done, but will
|
||||||
|
* not do the post effect of the task (like schedule the next routine
|
||||||
|
* of [[RoutineTask]]).
|
||||||
|
*
|
||||||
|
* @return this scheduler for chained call.
|
||||||
|
*/
|
||||||
|
@targetName("cancelIt")
|
||||||
|
def % (task: Task): this.type =
|
||||||
|
cancel(task)
|
||||||
|
this
|
||||||
|
/** Remove the task from scheduler task queue.
|
||||||
|
*
|
||||||
|
* If the removal task is running, the method will wait for the current run
|
||||||
|
* complete (and current run post effect complete), then do remove.
|
||||||
|
*
|
||||||
|
* @return [[true]] if the task is in task queue or is running, and have been
|
||||||
|
* succeed removed from task queue.
|
||||||
|
*/
|
||||||
|
def cancel (task: Task): Boolean =
|
||||||
|
taskList synchronized:
|
||||||
|
try taskList remove task
|
||||||
|
finally taskList.notifyAll()
|
||||||
|
|
||||||
|
/** Count of tasks in the task queue.
|
||||||
|
*
|
||||||
|
* Do not contains the running task.
|
||||||
|
*/
|
||||||
|
def amount: Int =
|
||||||
|
taskList.size
|
||||||
|
|
||||||
|
/** Current [[State status]] */
|
||||||
|
def state: this.State =
|
||||||
|
runtimeStatus
|
||||||
|
|
||||||
|
/** This scheduler's runner thread state */
|
||||||
|
def runnerState: Thread.State =
|
||||||
|
runtime.getState
|
||||||
|
|
||||||
|
/** Manually update the task scheduler.
|
||||||
|
*
|
||||||
|
* If the inner state of the scheduler somehow changed and cannot automatically
|
||||||
|
* update schedule states to schedule the new state, you can call this method
|
||||||
|
* to manually let the task scheduler reschedule it.
|
||||||
|
*
|
||||||
|
* You can also use it with some tick-guard like [[cc.sukazyo.cono.morny.util.time.WatchDog]]
|
||||||
|
* to make the scheduler avoid fails when machine fall asleep or some else conditions.
|
||||||
|
*/
|
||||||
|
def notifyIt(): Unit =
|
||||||
|
taskList synchronized:
|
||||||
|
taskList.notifyAll()
|
||||||
|
|
||||||
|
/** Stop the scheduler's runner, no matter how much task is not run yet.
|
||||||
|
*
|
||||||
|
* After call this, it will immediately give a signal to the runner for
|
||||||
|
* stopping it. If the runner is not running any task, it will stop immediately;
|
||||||
|
* If there's one task running, the runner will continue executing until
|
||||||
|
* the current task is done and the current task's post effect is done, then
|
||||||
|
* stop.
|
||||||
|
*
|
||||||
|
* This method is async, means complete this method does not means the
|
||||||
|
* runner is stopped. If you want a sync version, see [[waitForStop]].
|
||||||
|
*/
|
||||||
|
def stop (): Unit =
|
||||||
|
taskList synchronized:
|
||||||
|
exitAtNextRoutine = true
|
||||||
|
taskList.notifyAll()
|
||||||
|
|
||||||
|
/** Stop the scheduler's runner, no matter how much task is not run yet,
|
||||||
|
* and wait for the runner stopped.
|
||||||
|
*
|
||||||
|
* It do the same job with [[stop]], the only different is this method
|
||||||
|
* will join the runner thread to wait it stopped.
|
||||||
|
*
|
||||||
|
* @throws InterruptedException if any thread has interrupted the current
|
||||||
|
* thread. The interrupted status of the current
|
||||||
|
* thread is cleared when this exception is thrown.
|
||||||
|
*/
|
||||||
|
@throws[InterruptedException]
|
||||||
|
def waitForStop (): Unit =
|
||||||
|
stop()
|
||||||
|
runtime.join()
|
||||||
|
|
||||||
|
/** Tag this scheduler runner stop when all of the scheduler's task in task
|
||||||
|
* queue have been stopped.
|
||||||
|
*
|
||||||
|
* After called this method, the runner will exit when all tasks executed done
|
||||||
|
* and there's no more task can be found in task queue.
|
||||||
|
*
|
||||||
|
* Notice that if there's [[RoutineTask]] in task queue, due to the routine
|
||||||
|
* task will re-enter the task queue in task's post effect stage after executed,
|
||||||
|
* it will cause the task queue will never be empty. You may need to remove all
|
||||||
|
* routine tasks before calling this.
|
||||||
|
*
|
||||||
|
* This method is async, means complete this method does not means the
|
||||||
|
* runner is stopped. If you want a sync version, see [[waitForStopAtAllDone]].
|
||||||
|
*/
|
||||||
|
//noinspection ScalaWeakerAccess
|
||||||
|
def tagStopAtAllDone (): Unit =
|
||||||
|
taskList synchronized:
|
||||||
|
waitForDone = true
|
||||||
|
taskList.notifyAll()
|
||||||
|
|
||||||
|
/** Tag this scheduler runner stop when all of the scheduler's task in task
|
||||||
|
* queue have been stopped, and wait for the runner stopped.
|
||||||
|
*
|
||||||
|
* It do the same job with [[tagStopAtAllDone]], the only different is this method
|
||||||
|
* will join the runner thread to wait it stopped.
|
||||||
|
*
|
||||||
|
* @throws InterruptedException if any thread has interrupted the current
|
||||||
|
* thread. The interrupted status of the current
|
||||||
|
* thread is cleared when this exception is thrown.
|
||||||
|
*/
|
||||||
|
@throws[InterruptedException]
|
||||||
|
def waitForStopAtAllDone(): Unit =
|
||||||
|
tagStopAtAllDone()
|
||||||
|
runtime.join()
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,67 @@
|
|||||||
|
package cc.sukazyo.cono.morny.util.schedule
|
||||||
|
|
||||||
|
import cc.sukazyo.cono.morny.util.EpochDateTime.EpochMillis
|
||||||
|
|
||||||
|
/** A schedule task that can be added to [[Scheduler]].
|
||||||
|
*
|
||||||
|
* Contains some basic task information: [[name]], [[scheduledTimeMillis]],
|
||||||
|
* and [[main]] as the method which will be called.
|
||||||
|
*
|
||||||
|
* Tasks are ordered by time, and makes sure that two different task instance
|
||||||
|
* is NOT THE SAME.
|
||||||
|
* <blockquote>
|
||||||
|
* 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.
|
||||||
|
* </blockquote>
|
||||||
|
*/
|
||||||
|
trait Task extends Ordered[Task] {
|
||||||
|
|
||||||
|
/** Task name. Also the executor thread name when task is executing.
|
||||||
|
*
|
||||||
|
* Will be used in [[Scheduler]] to change the running thread's name.
|
||||||
|
*/
|
||||||
|
def name: String
|
||||||
|
/** Next running time.
|
||||||
|
*
|
||||||
|
* If it is smaller than current time, the task should be executed immediately.
|
||||||
|
*/
|
||||||
|
def scheduledTimeMillis: EpochMillis
|
||||||
|
|
||||||
|
//noinspection UnitMethodIsParameterless
|
||||||
|
def main: Unit
|
||||||
|
|
||||||
|
override def compare (that: Task): Int =
|
||||||
|
scheduledTimeMillis.compareTo(that.scheduledTimeMillis) match
|
||||||
|
case 0 => this.hashCode - that.hashCode
|
||||||
|
case n => n
|
||||||
|
|
||||||
|
/** Returns this task's object name and the task name.
|
||||||
|
*
|
||||||
|
* for example:
|
||||||
|
* {{{
|
||||||
|
* scala> val task = new Task {
|
||||||
|
* val name = "example-task"
|
||||||
|
* val scheduledTimeMillis = 0
|
||||||
|
* def main = println("example")
|
||||||
|
* }
|
||||||
|
* val task: cc.sukazyo.cono.morny.util.schedule.Task = anon$1@26d8908e{"example-task": 0}
|
||||||
|
*
|
||||||
|
* scala> task.toString
|
||||||
|
* val res0: String = anon$1@26d8908e{"example-task": 0}
|
||||||
|
* }}}
|
||||||
|
*/
|
||||||
|
override def toString: String =
|
||||||
|
s"""${super.toString}{"$name": $scheduledTimeMillis}"""
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
object Task {
|
||||||
|
|
||||||
|
def apply (_name: String, _scheduledTime: EpochMillis, _main: =>Unit): Task =
|
||||||
|
new Task:
|
||||||
|
override def name: String = _name
|
||||||
|
override def scheduledTimeMillis: EpochMillis = _scheduledTime
|
||||||
|
override def main: Unit = _main
|
||||||
|
|
||||||
|
}
|
@ -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
|
||||||
|
|
||||||
|
}
|
@ -2,7 +2,7 @@ package cc.sukazyo.cono.morny.util.tgapi
|
|||||||
|
|
||||||
import cc.sukazyo.cono.morny.util.tgapi.event.EventRuntimeException
|
import cc.sukazyo.cono.morny.util.tgapi.event.EventRuntimeException
|
||||||
import com.pengrad.telegrambot.TelegramBot
|
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.request.{BaseRequest, GetChatMember}
|
||||||
import com.pengrad.telegrambot.response.BaseResponse
|
import com.pengrad.telegrambot.response.BaseResponse
|
||||||
|
|
||||||
@ -12,13 +12,19 @@ object TelegramExtensions {
|
|||||||
|
|
||||||
object Bot { extension (bot: TelegramBot) {
|
object Bot { extension (bot: TelegramBot) {
|
||||||
|
|
||||||
|
@throws[EventRuntimeException]
|
||||||
def exec [T <: BaseRequest[T, R], R <: BaseResponse] (request: T, onError_message: String = ""): R = {
|
def exec [T <: BaseRequest[T, R], R <: BaseResponse] (request: T, onError_message: String = ""): R = {
|
||||||
val response = bot execute request
|
try {
|
||||||
if response isOk then return response
|
val response = bot execute request
|
||||||
throw EventRuntimeException.ActionFailed(
|
if response isOk then return response
|
||||||
if onError_message isEmpty then response.errorCode toString else onError_message,
|
throw EventRuntimeException.ActionFailed(
|
||||||
response
|
if onError_message isEmpty then response.errorCode toString else onError_message,
|
||||||
)
|
response
|
||||||
|
)
|
||||||
|
} catch
|
||||||
|
case e: EventRuntimeException.ActionFailed => throw e
|
||||||
|
case e: RuntimeException =>
|
||||||
|
throw EventRuntimeException.ClientFailed(e)
|
||||||
}
|
}
|
||||||
|
|
||||||
}}
|
}}
|
||||||
@ -60,6 +66,14 @@ object TelegramExtensions {
|
|||||||
|
|
||||||
}}
|
}}
|
||||||
|
|
||||||
|
object Message { extension (self: Message) {
|
||||||
|
|
||||||
|
def entitiesSafe: List[MessageEntity] =
|
||||||
|
if self.entities == null then Nil else
|
||||||
|
self.entities.toList
|
||||||
|
|
||||||
|
}}
|
||||||
|
|
||||||
class LimboUser (id: Long) extends User(id)
|
class LimboUser (id: Long) extends User(id)
|
||||||
class LimboChat (val _id: Long) extends Chat() {
|
class LimboChat (val _id: Long) extends Chat() {
|
||||||
override val id: java.lang.Long = _id
|
override val id: java.lang.Long = _id
|
||||||
|
@ -2,8 +2,26 @@ package cc.sukazyo.cono.morny.util.tgapi.event
|
|||||||
|
|
||||||
import com.pengrad.telegrambot.response.BaseResponse
|
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 {
|
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)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,11 @@
|
|||||||
package cc.sukazyo.cono.morny.util.tgapi.formatting
|
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 {
|
object TelegramParseEscape {
|
||||||
|
|
||||||
def escapeHtml (input: String): String =
|
def escapeHtml (input: String): String =
|
||||||
@ -9,4 +15,55 @@ object TelegramParseEscape {
|
|||||||
process = process.replaceAll(">", ">")
|
process = process.replaceAll(">", ">")
|
||||||
process
|
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
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
package cc.sukazyo.cono.morny.util.tgapi.formatting
|
package cc.sukazyo.cono.morny.util.tgapi.formatting
|
||||||
|
|
||||||
|
import cc.sukazyo.cono.morny.util.SttpPublic.mornyBasicRequest
|
||||||
import com.pengrad.telegrambot.model.User
|
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 sttp.client3.okhttp.OkHttpSyncBackend
|
||||||
|
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
@ -17,7 +18,7 @@ object TelegramUserInformation {
|
|||||||
def getDataCenterFromUser (username: String): String = {
|
def getDataCenterFromUser (username: String): String = {
|
||||||
|
|
||||||
try
|
try
|
||||||
val body = basicRequest
|
val body = mornyBasicRequest
|
||||||
.get(uri"https://t.me/$username")
|
.get(uri"https://t.me/$username")
|
||||||
.response(asString.getRight)
|
.response(asString.getRight)
|
||||||
.send(httpClient)
|
.send(httpClient)
|
||||||
|
@ -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)
|
||||||
|
|
||||||
|
}
|
@ -16,10 +16,19 @@ class OnQuestionMarkReplyTest extends MornyTests with TableDrivenPropertyChecks
|
|||||||
("为什么?", false),
|
("为什么?", false),
|
||||||
("?这不合理", false),
|
("?这不合理", false),
|
||||||
("??尊嘟假嘟", false),
|
("??尊嘟假嘟", false),
|
||||||
|
(":¿", false),
|
||||||
("?????", true),
|
("?????", true),
|
||||||
|
("¿", true),
|
||||||
|
("⁈??", true),
|
||||||
|
("?!??", false),
|
||||||
|
("⁇", true),
|
||||||
|
("‽", true),
|
||||||
|
("?⸘?", true),
|
||||||
("?", true),
|
("?", true),
|
||||||
("?", true),
|
("??", true),
|
||||||
("??❔", true),
|
("❔", true),
|
||||||
|
("❓❓❓", true),
|
||||||
|
// ("⁉️", true)
|
||||||
)
|
)
|
||||||
forAll(examples) { (text, is) =>
|
forAll(examples) { (text, is) =>
|
||||||
|
|
||||||
|
@ -1,9 +1,8 @@
|
|||||||
package cc.sukazyo.cono.morny.test.data
|
package cc.sukazyo.cono.morny.test.extra
|
||||||
|
|
||||||
import cc.sukazyo.cono.morny.data.BilibiliForms.*
|
import cc.sukazyo.cono.morny.extra.BilibiliForms.*
|
||||||
import cc.sukazyo.cono.morny.test.MornyTests
|
import cc.sukazyo.cono.morny.test.MornyTests
|
||||||
import org.scalatest.prop.TableDrivenPropertyChecks
|
import org.scalatest.prop.TableDrivenPropertyChecks
|
||||||
import org.scalatest.tagobjects.{Network, Slow}
|
|
||||||
|
|
||||||
class BilibiliFormsTest extends MornyTests with TableDrivenPropertyChecks {
|
class BilibiliFormsTest extends MornyTests with TableDrivenPropertyChecks {
|
||||||
|
|
||||||
@ -89,29 +88,34 @@ class BilibiliFormsTest extends MornyTests with TableDrivenPropertyChecks {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
"while destruct b23.tv share link :" - {
|
// Due to this url is expirable, I have no energy to update links in time.
|
||||||
|
// So I decide to deprecate the tests.
|
||||||
val examples = Table(
|
// "while destruct b23.tv share link :" - {
|
||||||
("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"),
|
// val examples = Table(
|
||||||
("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")
|
// ("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"),
|
||||||
"not b23.tv link is not supported" in:
|
// ("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")
|
||||||
an[IllegalArgumentException] should be thrownBy destructB23Url("sukazyo.cc/2xhUHO2e")
|
// // this link have been expired
|
||||||
an[IllegalArgumentException] should be thrownBy destructB23Url("https://sukazyo.cc/2xhUHO2e")
|
//// ("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")
|
||||||
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")
|
// "not b23.tv link is not supported" in:
|
||||||
"b23.tv/avXXX video link is not supported" in:
|
// an[IllegalArgumentException] should be thrownBy destructB23Url("sukazyo.cc/2xhUHO2e")
|
||||||
an[IllegalArgumentException] should be thrownBy destructB23Url("https://b23.tv/av123456")
|
// an[IllegalArgumentException] should be thrownBy destructB23Url("https://sukazyo.cc/2xhUHO2e")
|
||||||
an[IllegalArgumentException] should be thrownBy destructB23Url("https://b23.tv/BV1Q541167Qg")
|
// an[IllegalArgumentException] should be thrownBy destructB23Url("长月烬明澹台烬心理分析向解析(一)因果之锁,渡魔之路")
|
||||||
|
// an[IllegalArgumentException] should be thrownBy destructB23Url("https://b23.tvb/JDo2eaD")
|
||||||
forAll (examples) { (origin, result) =>
|
// an[IllegalArgumentException] should be thrownBy destructB23Url("https://ab23.tv/JDo2eaD")
|
||||||
s"b23 link $origin should be destructed to $result" taggedAs (Slow, Network) in:
|
// "b23.tv/avXXX video link is not supported" in:
|
||||||
destructB23Url(origin) shouldEqual result
|
// 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
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// }
|
||||||
|
|
||||||
}
|
}
|
@ -0,0 +1,82 @@
|
|||||||
|
package cc.sukazyo.cono.morny.test.extra.twitter
|
||||||
|
|
||||||
|
import cc.sukazyo.cono.morny.extra.twitter.FXApi
|
||||||
|
import cc.sukazyo.cono.morny.extra.twitter.FXApi.Fetch
|
||||||
|
import cc.sukazyo.cono.morny.test.MornyTests
|
||||||
|
import org.scalatest.prop.TableDrivenPropertyChecks
|
||||||
|
import org.scalatest.tagobjects.{Network, Slow}
|
||||||
|
|
||||||
|
//noinspection ScalaUnusedExpression
|
||||||
|
class FXApiTest extends MornyTests with TableDrivenPropertyChecks {
|
||||||
|
|
||||||
|
"while fetch status (tweet) :" - {
|
||||||
|
|
||||||
|
"non exists tweet id should return 404" taggedAs (Slow, Network) in {
|
||||||
|
val api = Fetch.status(Some("some_non_exists"), "-1")
|
||||||
|
api.code shouldEqual 404
|
||||||
|
api.message shouldEqual "NOT_FOUND"
|
||||||
|
api.tweet shouldBe empty
|
||||||
|
}
|
||||||
|
|
||||||
|
/** It should return 401, but in practice it seems will only
|
||||||
|
* return 404.
|
||||||
|
*/
|
||||||
|
"private tweet should return 410 or 404" taggedAs (Slow, Network) in {
|
||||||
|
val api = Fetch.status(Some("_takiChan"), "1586671758999924736")
|
||||||
|
api.code should (equal (404) or equal (401))
|
||||||
|
api.code match
|
||||||
|
case 401 =>
|
||||||
|
api.message shouldEqual "PRIVATE_TWEET"
|
||||||
|
note("from private tweet got 401 PRIVATE_TWEET")
|
||||||
|
case 404 =>
|
||||||
|
api.message shouldEqual "NOT_FOUND"
|
||||||
|
note("from private tweet got 404 NOT_FOUND")
|
||||||
|
api.tweet shouldBe empty
|
||||||
|
}
|
||||||
|
|
||||||
|
val examples = Table[(Option[String], String), FXApi =>Unit](
|
||||||
|
("id", "checking"),
|
||||||
|
((Some("_Eyre_S"), "1669362743332438019"), api => {
|
||||||
|
api.tweet shouldBe defined
|
||||||
|
api.tweet.get.text shouldEqual "猫头猫头鹰头猫头鹰头猫头鹰"
|
||||||
|
api.tweet.get.quote shouldBe defined
|
||||||
|
api.tweet.get.quote.get.id shouldEqual "1669302279386828800"
|
||||||
|
}),
|
||||||
|
((None, "1669362743332438019"), api => {
|
||||||
|
api.tweet shouldBe defined
|
||||||
|
api.tweet.get.text shouldEqual "猫头猫头鹰头猫头鹰头猫头鹰"
|
||||||
|
api.tweet.get.quote shouldBe defined
|
||||||
|
api.tweet.get.quote.get.id shouldEqual "1669302279386828800"
|
||||||
|
}),
|
||||||
|
((None, "1654080016802807809"), api => {
|
||||||
|
api.tweet shouldBe defined
|
||||||
|
api.tweet.get.media shouldBe defined
|
||||||
|
api.tweet.get.media.get.videos shouldBe empty
|
||||||
|
api.tweet.get.media.get.photos shouldBe defined
|
||||||
|
api.tweet.get.media.get.photos.get.length shouldBe 1
|
||||||
|
api.tweet.get.media.get.photos.get.head.width shouldBe 2048
|
||||||
|
api.tweet.get.media.get.photos.get.head.height shouldBe 1536
|
||||||
|
api.tweet.get.media.get.mosaic shouldBe empty
|
||||||
|
}),
|
||||||
|
((None, "1538536152093044736"), api => {
|
||||||
|
api.tweet shouldBe defined
|
||||||
|
api.tweet.get.media shouldBe defined
|
||||||
|
api.tweet.get.media.get.videos shouldBe empty
|
||||||
|
api.tweet.get.media.get.photos shouldBe defined
|
||||||
|
api.tweet.get.media.get.photos.get.length shouldBe 2
|
||||||
|
api.tweet.get.media.get.photos.get.head.width shouldBe 2894
|
||||||
|
api.tweet.get.media.get.photos.get.head.height shouldBe 4093
|
||||||
|
api.tweet.get.media.get.photos.get(1).width shouldBe 2894
|
||||||
|
api.tweet.get.media.get.photos.get(1).height shouldBe 4093
|
||||||
|
api.tweet.get.media.get.mosaic shouldBe defined
|
||||||
|
})
|
||||||
|
)
|
||||||
|
forAll(examples) { (data, assertion) =>
|
||||||
|
s"tweet $data should be fetched successful" taggedAs (Slow, Network) in {
|
||||||
|
assertion(Fetch.status(data._1, data._2))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,123 @@
|
|||||||
|
package cc.sukazyo.cono.morny.test.extra.twitter
|
||||||
|
|
||||||
|
import cc.sukazyo.cono.morny.extra.twitter.{parseTweetUrl, TweetUrlInformation}
|
||||||
|
import cc.sukazyo.cono.morny.test.MornyTests
|
||||||
|
|
||||||
|
class PackageTest extends MornyTests {
|
||||||
|
|
||||||
|
"while parsing tweet url :" - {
|
||||||
|
|
||||||
|
"normal twitter tweet share url should be parsed" in {
|
||||||
|
parseTweetUrl("https://twitter.com/ps_urine/status/1727614825755505032?s=20")
|
||||||
|
.shouldEqual(Some(TweetUrlInformation(
|
||||||
|
"twitter.com", "ps_urine/status/1727614825755505032",
|
||||||
|
"ps_urine", "1727614825755505032",
|
||||||
|
None, Some("s=20")
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
"normal X.com tweet share url should be parsed" in {
|
||||||
|
parseTweetUrl("https://x.com/ps_urine/status/1727614825755505032?s=20")
|
||||||
|
.shouldBe(defined)
|
||||||
|
}
|
||||||
|
|
||||||
|
"X.com or twitter tweet share url should not www.sensitive" in {
|
||||||
|
parseTweetUrl("https://www.twitter.com/ps_urine/status/1727614825755505032?s=20")
|
||||||
|
.shouldBe(defined)
|
||||||
|
parseTweetUrl("https://www.x.com/ps_urine/status/1727614825755505032?s=20")
|
||||||
|
.shouldBe(defined)
|
||||||
|
}
|
||||||
|
|
||||||
|
"fxtwitter and fixupx url should be parsed" in {
|
||||||
|
parseTweetUrl("https://fxtwitter.com/ps_urine/status/1727614825755505032?s=20")
|
||||||
|
.shouldBe(defined)
|
||||||
|
parseTweetUrl("https://fixupx.com/ps_urine/status/1727614825755505032?s=20")
|
||||||
|
.shouldBe(defined)
|
||||||
|
}
|
||||||
|
"vxtwitter should be parsed and can be with c." in {
|
||||||
|
parseTweetUrl("https://vxtwitter.com/ps_urine/status/1727614825755505032?s=20")
|
||||||
|
.shouldBe(defined)
|
||||||
|
parseTweetUrl("https://c.vxtwitter.com/ps_urine/status/1727614825755505032?s=20")
|
||||||
|
.shouldBe(defined)
|
||||||
|
}
|
||||||
|
"fixvx should be parsed and cannot be with c." in {
|
||||||
|
parseTweetUrl("https://fixvx.com/ps_urine/status/1727614825755505032?s=20")
|
||||||
|
.shouldBe(defined)
|
||||||
|
parseTweetUrl("https://c.fixvx.com/ps_urine/status/1727614825755505032?s=20")
|
||||||
|
.shouldBe(empty)
|
||||||
|
}
|
||||||
|
|
||||||
|
"fxtwitter and vxtwitter should not contains www." in {
|
||||||
|
parseTweetUrl("https://www.fxtwitter.com/ps_urine/status/1727614825755505032?s=20")
|
||||||
|
.shouldBe(empty)
|
||||||
|
parseTweetUrl("https://www.fixupx.com/ps_urine/status/1727614825755505032?s=20")
|
||||||
|
.shouldBe(empty)
|
||||||
|
parseTweetUrl("https://www.vxtwitter.com/ps_urine/status/1727614825755505032?s=20")
|
||||||
|
.shouldBe(empty)
|
||||||
|
parseTweetUrl("https://www.fixvx.com/ps_urine/status/1727614825755505032?s=20")
|
||||||
|
.shouldBe(empty)
|
||||||
|
}
|
||||||
|
|
||||||
|
"url should be http/s non-sensitive" in {
|
||||||
|
parseTweetUrl("twitter.com/ps_urine/status/1727614825755505032?s=20")
|
||||||
|
.shouldBe(defined)
|
||||||
|
parseTweetUrl("http://x.com/ps_urine/status/1727614825755505032?s=20")
|
||||||
|
.shouldBe(defined)
|
||||||
|
parseTweetUrl("http://fxtwitter.com/ps_urine/status/1727614825755505032?s=20")
|
||||||
|
.shouldBe(defined)
|
||||||
|
parseTweetUrl("http://fixupx.com/ps_urine/status/1727614825755505032?s=20")
|
||||||
|
.shouldBe(defined)
|
||||||
|
parseTweetUrl("vxtwitter.com/ps_urine/status/1727614825755505032?s=20")
|
||||||
|
.shouldBe(defined)
|
||||||
|
parseTweetUrl("fixvx.com/ps_urine/status/1727614825755505032?s=20")
|
||||||
|
.shouldBe(defined)
|
||||||
|
}
|
||||||
|
|
||||||
|
"url param should be non-sensitive" in {
|
||||||
|
parseTweetUrl("twitter.com/ps_urine/status/1727614825755505032")
|
||||||
|
.shouldBe(defined)
|
||||||
|
parseTweetUrl("http://x.com/ps_urine/status/1727614825755505032/?q=ajisdl&form=ANNNB1&refig=5883b79c966b4881b79b50cb6f1c6c6a")
|
||||||
|
.shouldBe(defined)
|
||||||
|
parseTweetUrl("http://fxtwitter.com/ps_urine/status/1727614825755505032/?s=20")
|
||||||
|
.shouldBe(defined)
|
||||||
|
parseTweetUrl("http://fixupx.com/ps_urine/status/1727614825755505032?s=20")
|
||||||
|
.shouldBe(defined)
|
||||||
|
parseTweetUrl("vxtwitter.com/ps_urine/status/1727614825755505032")
|
||||||
|
.shouldBe(defined)
|
||||||
|
parseTweetUrl("fixvx.com/ps_urine/status/1727614825755505032?q=ajisdl&form=ANNNB1&refig=5883b79c966b4881b79b50cb6f1c6c6a")
|
||||||
|
.shouldBe(defined)
|
||||||
|
}
|
||||||
|
|
||||||
|
"screen name should not be non-exists" in {
|
||||||
|
parseTweetUrl("twitter.com/status/1727614825755505032")
|
||||||
|
.shouldBe(empty)
|
||||||
|
parseTweetUrl("http://x.com/status/1727614825755505032/?q=ajisdl&form=ANNNB1&refig=5883b79c966b4881b79b50cb6f1c6c6a")
|
||||||
|
.shouldBe(empty)
|
||||||
|
parseTweetUrl("http://fxtwitter.com/status/1727614825755505032/?s=20")
|
||||||
|
.shouldBe(empty)
|
||||||
|
parseTweetUrl("http://fixupx.com/status/1727614825755505032?s=20")
|
||||||
|
.shouldBe(empty)
|
||||||
|
parseTweetUrl("vxtwitter.com/status/1727614825755505032")
|
||||||
|
.shouldBe(empty)
|
||||||
|
parseTweetUrl("fixvx.com/status/1727614825755505032?q=ajisdl&form=ANNNB1&refig=5883b79c966b4881b79b50cb6f1c6c6a")
|
||||||
|
.shouldBe(empty)
|
||||||
|
}
|
||||||
|
|
||||||
|
"url with photo id should be parsed" in {
|
||||||
|
parseTweetUrl("twitter.com/ps_urine/status/1727614825755505032/photo/2")
|
||||||
|
.should(matchPattern { case Some(TweetUrlInformation(_, _, _, _, Some("2"), _)) => })
|
||||||
|
parseTweetUrl("http://x.com/ps_urine/status/1727614825755505032/photo/1/?q=ajisdl&form=ANNNB1&refig=5883b79c966b4881b79b50cb6f1c6c6a")
|
||||||
|
.should(matchPattern { case Some(TweetUrlInformation(_, _, _, _, Some("1"), _)) => })
|
||||||
|
parseTweetUrl("http://fxtwitter.com/ps_urine/status/1727614825755505032/photo/4/?s=20")
|
||||||
|
.should(matchPattern { case Some(TweetUrlInformation(_, _, _, _, Some("4"), _)) => })
|
||||||
|
parseTweetUrl("http://fixupx.com/ps_urine/status/1727614825755505032/photo/7?s=20")
|
||||||
|
.should(matchPattern { case Some(TweetUrlInformation(_, _, _, _, Some("7"), _)) => })
|
||||||
|
parseTweetUrl("vxtwitter.com/ps_urine/status/1727614825755505032/photo/114514")
|
||||||
|
.should(matchPattern { case Some(TweetUrlInformation(_, _, _, _, Some("114514"), _)) => })
|
||||||
|
parseTweetUrl("fixvx.com/ps_urine/status/1727614825755505032/photo/unavailable-id?q=ajisdl&form=ANNNB1&refig=5883b79c966b4881b79b50cb6f1c6c6a")
|
||||||
|
.shouldBe(empty)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -1,11 +1,55 @@
|
|||||||
package cc.sukazyo.cono.morny.test.utils
|
package cc.sukazyo.cono.morny.test.utils
|
||||||
|
|
||||||
import cc.sukazyo.cono.morny.test.MornyTests
|
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
|
import org.scalatest.prop.TableDrivenPropertyChecks
|
||||||
|
|
||||||
class EpochDateTimeTest extends MornyTests with 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 converting date-time string to time-millis : " - {
|
||||||
|
|
||||||
"while using ISO-Offset-Date-Time : " - {
|
"while using ISO-Offset-Date-Time : " - {
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,23 @@
|
|||||||
|
package cc.sukazyo.cono.morny.test.utils.schedule
|
||||||
|
|
||||||
|
import cc.sukazyo.cono.morny.test.MornyTests
|
||||||
|
import cc.sukazyo.cono.morny.util.schedule.{IntervalWithTimesTask, Scheduler}
|
||||||
|
import org.scalatest.tagobjects.Slow
|
||||||
|
|
||||||
|
class IntervalsTest extends MornyTests {
|
||||||
|
|
||||||
|
"IntervalWithTimesTest should work even scheduler is scheduled to stop" taggedAs Slow in {
|
||||||
|
val scheduler = Scheduler()
|
||||||
|
var times = 0
|
||||||
|
scheduler ++ IntervalWithTimesTask("intervals-10", 200, 10, {
|
||||||
|
times = times + 1
|
||||||
|
})
|
||||||
|
val startTime = System.currentTimeMillis()
|
||||||
|
scheduler.waitForStopAtAllDone()
|
||||||
|
val timeUsed = System.currentTimeMillis() - startTime
|
||||||
|
times shouldEqual 10
|
||||||
|
timeUsed should (be <= 2100L and be >= 1900L)
|
||||||
|
info(s"Interval Task with interval 200ms for 10 times used time ${timeUsed}ms")
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,49 @@
|
|||||||
|
package cc.sukazyo.cono.morny.test.utils.schedule
|
||||||
|
|
||||||
|
import cc.sukazyo.cono.morny.test.MornyTests
|
||||||
|
import cc.sukazyo.cono.morny.util.schedule.{DelayedTask, Scheduler, Task}
|
||||||
|
import org.scalatest.tagobjects.Slow
|
||||||
|
|
||||||
|
import scala.collection.mutable
|
||||||
|
|
||||||
|
class SchedulerTest extends MornyTests {
|
||||||
|
|
||||||
|
"While executing tasks using scheduler :" - {
|
||||||
|
|
||||||
|
"Task with scheduleTime smaller than current time should be executed immediately" in {
|
||||||
|
val scheduler = Scheduler()
|
||||||
|
val time = System.currentTimeMillis
|
||||||
|
var doneTime: Option[Long] = None
|
||||||
|
scheduler ++ Task("task", 0, {
|
||||||
|
doneTime = Some(System.currentTimeMillis)
|
||||||
|
})
|
||||||
|
Thread.sleep(10)
|
||||||
|
scheduler.stop()
|
||||||
|
doneTime shouldBe defined
|
||||||
|
info(s"Immediately Task done in ${doneTime.get - time}ms")
|
||||||
|
}
|
||||||
|
|
||||||
|
"Task's running thread name should be task name" in {
|
||||||
|
val scheduler = Scheduler()
|
||||||
|
var executedThread: Option[String] = None
|
||||||
|
scheduler ++ Task("task", 0, {
|
||||||
|
executedThread = Some(Thread.currentThread.getName)
|
||||||
|
})
|
||||||
|
scheduler.waitForStopAtAllDone()
|
||||||
|
executedThread shouldEqual Some("task")
|
||||||
|
}
|
||||||
|
|
||||||
|
"Task's execution order should be ordered by task Ordering but not insert order" taggedAs Slow in {
|
||||||
|
val scheduler = Scheduler()
|
||||||
|
val result = mutable.ArrayBuffer.empty[String]
|
||||||
|
scheduler
|
||||||
|
++ DelayedTask("task-later", 400L, { result += Thread.currentThread.getName })
|
||||||
|
++ DelayedTask("task-very-late", 800L, { result += Thread.currentThread.getName })
|
||||||
|
++ DelayedTask("task-early", 100L, { result += Thread.currentThread.getName })
|
||||||
|
scheduler.waitForStopAtAllDone()
|
||||||
|
result.toArray shouldEqual Array("task-early", "task-later", "task-very-late")
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -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"
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user