mirror of
https://github.com/Eyre-S/Coeur-Morny-Cono.git
synced 2024-11-22 03:04:54 +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: 'resource-tools', version: lib_resourcetools_v
|
||||
testImplementation group: 'cc.sukazyo', name: 'resource-tools', version: lib_resourcetools_v
|
||||
|
||||
implementation group: 'com.github.pengrad', name: 'java-telegram-bot-api', version: lib_javatelegramapi_v
|
||||
implementation group: 'com.softwaremill.sttp.client3', name: scala('core'), version: lib_sttp_v
|
||||
implementation group: 'com.softwaremill.sttp.client3', name: scala('okhttp-backend'), version: lib_sttp_v
|
||||
implementation group: 'com.squareup.okhttp3', name: 'okhttp', version: lib_okhttp_v
|
||||
runtimeOnly group: 'com.squareup.okhttp3', name: 'okhttp', version: lib_okhttp_v
|
||||
implementation group: 'com.google.code.gson', name: 'gson', version: lib_gson_v
|
||||
implementation group: 'io.circe', name: scala('circe-core'), version: lib_circe_v
|
||||
implementation group: 'io.circe', name: scala('circe-generic'), version: lib_circe_v
|
||||
implementation group: 'io.circe', name: scala('circe-parser'), version: lib_circe_v
|
||||
implementation group: 'org.jsoup', name: 'jsoup', version: '1.16.2'
|
||||
implementation group: 'com.cronutils', name: 'cron-utils', version: lib_cron_utils_v
|
||||
|
||||
// used for disable slf4j
|
||||
// due to the slf4j api have been used in the following libraries:
|
||||
// - cron-utils
|
||||
runtimeOnly group: 'org.slf4j', name: 'slf4j-nop', version: lib_slf4j_v
|
||||
testRuntimeOnly group: 'org.slf4j', name: 'slf4j-nop', version: lib_slf4j_v
|
||||
|
||||
testImplementation group: 'cc.sukazyo', name: 'resource-tools', version: lib_resourcetools_v
|
||||
testImplementation group: 'org.scalatest', name: scala('scalatest'), version: lib_scalatest_v
|
||||
testImplementation group: 'org.scalatest', name: scala('scalatest-freespec'), version: lib_scalatest_v
|
||||
testRuntimeOnly group: 'org.scala-lang.modules', name: scala('scala-xml'), version: lib_scalamodule_xml_v
|
||||
|
||||
// for generating HTML report // required by gradle-scalatest plugin
|
||||
// for generating HTML report: required by gradle-scalatest plugin
|
||||
testRuntimeOnly group: 'com.vladsch.flexmark', name: 'flexmark-all', version: '0.64.6'
|
||||
|
||||
}
|
||||
@ -133,6 +143,7 @@ tasks.withType(ScalaCompile).configureEach {
|
||||
targetCompatibility proj_java.getMajorVersion()
|
||||
|
||||
scalaCompileOptions.additionalParameters.add "-language:postfixOps"
|
||||
scalaCompileOptions.additionalParameters.addAll ("-Xmax-inlines", "256")
|
||||
|
||||
scalaCompileOptions.encoding = proj_file_encoding.name()
|
||||
options.encoding = proj_file_encoding.name()
|
||||
|
@ -5,12 +5,12 @@ MORNY_ARCHIVE_NAME = morny-coeur
|
||||
MORNY_CODE_STORE = https://github.com/Eyre-S/Coeur-Morny-Cono
|
||||
MORNY_COMMIT_PATH = https://github.com/Eyre-S/Coeur-Morny-Cono/commit/%s
|
||||
|
||||
VERSION = 1.2.2-beta2
|
||||
VERSION = 1.3.0
|
||||
|
||||
USE_DELTA = false
|
||||
VERSION_DELTA =
|
||||
|
||||
CODENAME = xiongan
|
||||
CODENAME = guanggu
|
||||
|
||||
# dependencies
|
||||
|
||||
@ -19,11 +19,14 @@ lib_scalamodule_xml_v = 2.2.0
|
||||
|
||||
lib_messiva_v = 0.2.0
|
||||
lib_resourcetools_v = 0.2.2
|
||||
lib_slf4j_v = 2.0.9
|
||||
|
||||
lib_javatelegramapi_v = 6.2.0
|
||||
|
||||
lib_sttp_v = 3.9.0
|
||||
lib_okhttp_v = 4.11.0
|
||||
lib_gson_v = 2.10.1
|
||||
lib_circe_v = 0.14.6
|
||||
lib_cron_utils_v = 9.2.0
|
||||
|
||||
lib_scalatest_v = 3.2.17
|
||||
|
@ -7,9 +7,13 @@ import cc.sukazyo.cono.morny.MornyCoeur.THREAD_SERVER_EXIT
|
||||
import cc.sukazyo.cono.morny.bot.api.EventListenerManager
|
||||
import cc.sukazyo.cono.morny.bot.event.{MornyEventListeners, MornyOnInlineQuery, MornyOnTelegramCommand, MornyOnUpdateTimestampOffsetLock}
|
||||
import cc.sukazyo.cono.morny.bot.query.MornyQueries
|
||||
import cc.sukazyo.cono.morny.util.schedule.Scheduler
|
||||
import cc.sukazyo.cono.morny.util.EpochDateTime.EpochMillis
|
||||
import cc.sukazyo.cono.morny.util.time.WatchDog
|
||||
import com.pengrad.telegrambot.TelegramBot
|
||||
import com.pengrad.telegrambot.request.GetMe
|
||||
|
||||
import scala.annotation.unused
|
||||
import scala.util.boundary
|
||||
import scala.util.boundary.break
|
||||
|
||||
@ -53,7 +57,7 @@ class MornyCoeur (using val config: MornyConfig) {
|
||||
*
|
||||
* in milliseconds.
|
||||
*/
|
||||
val coeurStartTimestamp: Long = System.currentTimeMillis
|
||||
val coeurStartTimestamp: EpochMillis = System.currentTimeMillis
|
||||
|
||||
/** [[TelegramBot]] account of this Morny */
|
||||
val account: TelegramBot = __loginResult.account
|
||||
@ -62,6 +66,8 @@ class MornyCoeur (using val config: MornyConfig) {
|
||||
/** [[account]]'s telegram user id */
|
||||
val userid: Long = __loginResult.userid
|
||||
|
||||
/** Morny's task [[Scheduler]] */
|
||||
val tasks: Scheduler = Scheduler()
|
||||
/** current Morny's [[MornyTrusted]] instance */
|
||||
val trusted: MornyTrusted = MornyTrusted()
|
||||
|
||||
@ -76,12 +82,67 @@ class MornyCoeur (using val config: MornyConfig) {
|
||||
eventManager register MornyOnInlineQuery(using queries)
|
||||
//noinspection ScalaUnusedSymbol
|
||||
val events: MornyEventListeners = MornyEventListeners(using eventManager)
|
||||
eventManager register daemons.reporter.EventStatistics.EventInfoCatcher
|
||||
@unused
|
||||
val watchDog: WatchDog = WatchDog("watch-dog", 1000, 1500, { (consumed, _) =>
|
||||
import cc.sukazyo.cono.morny.util.CommonFormat.formatDuration as f
|
||||
logger warn
|
||||
s"""Can't keep up! is the server overloaded or host machine fall asleep?
|
||||
| current tick takes ${f(consumed)} to complete.""".stripMargin
|
||||
tasks.notifyIt()
|
||||
})
|
||||
|
||||
///>>> BLOCK START instance configure & startup stage 2
|
||||
|
||||
daemons.start()
|
||||
logger info "start telegram event listening"
|
||||
account setUpdatesListener eventManager
|
||||
import com.pengrad.telegrambot.TelegramException
|
||||
account.setUpdatesListener(eventManager, (e: TelegramException) => {
|
||||
|
||||
// This function intended to catch exceptions on update
|
||||
// fetching controlled by Telegram API Client. So that
|
||||
// it won't be directly printed to STDOUT without Morny's
|
||||
// logger. And it can be reported when needed.
|
||||
// TelegramException can either contains a caused that infers
|
||||
// a lower level client exception (network err or others);
|
||||
// nor contains a response that means API request failed.
|
||||
|
||||
if (e.response != null) {
|
||||
import com.google.gson.GsonBuilder
|
||||
logger error
|
||||
s"""Failed get updates: ${e.getMessage}
|
||||
| server responses:
|
||||
|${GsonBuilder().setPrettyPrinting().create.toJson(e.response) indent 4}
|
||||
|""".stripMargin
|
||||
this.daemons.reporter.exception(e, "Failed get updates.")
|
||||
}
|
||||
|
||||
if (e.getCause != null) {
|
||||
import java.net.{SocketException, SocketTimeoutException}
|
||||
import javax.net.ssl.SSLHandshakeException
|
||||
val caused = e.getCause
|
||||
caused match
|
||||
case e_timeout: (SSLHandshakeException|SocketException|SocketTimeoutException) =>
|
||||
import cc.sukazyo.messiva.log.Message
|
||||
|
||||
import scala.collection.mutable
|
||||
val log = mutable.ArrayBuffer(s"Failed get updates: Network Error")
|
||||
var current: Throwable = e_timeout
|
||||
log += s" due to: ${current.getClass.getSimpleName}: ${current.getMessage}"
|
||||
while (current.getCause != null) {
|
||||
current = current.getCause
|
||||
log += s" caused by: ${current.getClass.getSimpleName}: ${current.getMessage}"
|
||||
}
|
||||
logger error Message(log mkString "\n")
|
||||
case e_other =>
|
||||
logger error
|
||||
s"""Failed get updates:
|
||||
|${exceptionLog(e_other) indent 3}""".stripMargin
|
||||
this.daemons.reporter.exception(e_other, "Failed get updates.")
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
if config.commandLoginRefresh then
|
||||
logger info "resetting telegram command list"
|
||||
commands.automaticTGListUpdate()
|
||||
@ -101,6 +162,8 @@ class MornyCoeur (using val config: MornyConfig) {
|
||||
account.shutdown()
|
||||
logger info "stopped bot account"
|
||||
daemons.stop()
|
||||
tasks.waitForStop()
|
||||
logger info s"morny tasks stopped: remains ${tasks.amount} tasks not be executed"
|
||||
if config.commandLogoutClear then
|
||||
commands.automaticTGListRemove()
|
||||
logger info "done exit cleanup"
|
||||
@ -160,5 +223,5 @@ class MornyCoeur (using val config: MornyConfig) {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
@ -6,6 +6,7 @@ import java.lang.annotation.*;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.TimeZone;
|
||||
|
||||
public class MornyConfig {
|
||||
|
||||
@ -109,6 +110,18 @@ public class MornyConfig {
|
||||
*/
|
||||
public final long reportToChat;
|
||||
|
||||
/**
|
||||
* 控制 Morny Coeur 系统的报告的基准时间.
|
||||
* <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 *
|
||||
* ======================================= */
|
||||
@ -144,6 +157,7 @@ public class MornyConfig {
|
||||
this.dinnerTrustedReaders = prototype.dinnerTrustedReaders;
|
||||
this.dinnerChatId = prototype.dinnerChatId;
|
||||
this.reportToChat = prototype.reportToChat;
|
||||
this.reportZone = prototype.reportZone;
|
||||
this.medicationNotifyToChat = prototype.medicationNotifyToChat;
|
||||
this.medicationTimerUseTimezone = prototype.medicationTimerUseTimezone;
|
||||
prototype.medicationNotifyAt.forEach(i -> { if (i < 0 || i > 23) throw new CheckFailure.UnavailableTimeInMedicationNotifyAt(); });
|
||||
@ -173,6 +187,7 @@ public class MornyConfig {
|
||||
@Nonnull public final Set<Long> dinnerTrustedReaders = new HashSet<>();
|
||||
public long dinnerChatId = -1L;
|
||||
public long reportToChat = -1L;
|
||||
@Nonnull public TimeZone reportZone = TimeZone.getDefault();
|
||||
public long medicationNotifyToChat = -1L;
|
||||
@Nonnull public ZoneOffset medicationTimerUseTimezone = ZoneOffset.UTC;
|
||||
@Nonnull public final Set<Integer> medicationNotifyAt = new HashSet<>();
|
||||
|
@ -51,6 +51,7 @@ object ServerMain {
|
||||
case "--master" | "-mm" => i+=1 ; config.trustedMaster = args(i)toLong
|
||||
case "--trusted-chat" | "-trs" => i+=1 ; config.trustedChat = args(i)toLong
|
||||
case "--report-to" => i+=1; config.reportToChat = args(i)toLong
|
||||
case "--report-zone" => i+=1; config.reportZone = TimeZone.getTimeZone(args(i))
|
||||
|
||||
case "--trusted-reader-dinner" | "-trsd" => i+=1 ; config.dinnerTrustedReaders add (args(i)toLong)
|
||||
case "--dinner-chat" | "-chd" => i+=1 ; config.dinnerChatId = args(i)toLong
|
||||
|
@ -1,8 +1,11 @@
|
||||
package cc.sukazyo.cono.morny.bot.api
|
||||
|
||||
import cc.sukazyo.cono.morny.util.EpochDateTime.EpochMillis
|
||||
import cc.sukazyo.messiva.utils.StackUtils
|
||||
import com.pengrad.telegrambot.model.Update
|
||||
|
||||
import scala.collection.mutable
|
||||
import scala.reflect.{classTag, ClassTag}
|
||||
|
||||
class EventEnv (
|
||||
|
||||
@ -10,14 +13,34 @@ class EventEnv (
|
||||
|
||||
) {
|
||||
|
||||
private var _isOk: Int = 0
|
||||
private val variables: mutable.HashMap[Class[?], Any] = mutable.HashMap.empty
|
||||
trait StateSource (val from: StackTraceElement)
|
||||
enum State:
|
||||
case OK (_from: StackTraceElement) extends State with StateSource(_from)
|
||||
case CANCELED (_from: StackTraceElement) extends State with StateSource(_from)
|
||||
|
||||
def isEventOk: Boolean = _isOk > 0
|
||||
private val _status: mutable.ListBuffer[State] = mutable.ListBuffer.empty
|
||||
private val variables: mutable.HashMap[Class[?], Any] = mutable.HashMap.empty
|
||||
val timeStartup: EpochMillis = System.currentTimeMillis
|
||||
|
||||
def isEventOk: Boolean = _status.lastOption match
|
||||
case Some(x) if x == State.OK => true
|
||||
case _ => false
|
||||
|
||||
//noinspection UnitMethodIsParameterless
|
||||
def setEventOk: Unit =
|
||||
_isOk = _isOk + 1
|
||||
_status += State.OK(StackUtils.getStackTrace(1)(1))
|
||||
|
||||
//noinspection UnitMethodIsParameterless
|
||||
def setEventCanceled: Unit =
|
||||
_status += State.CANCELED(StackUtils.getStackTrace(1)(1))
|
||||
|
||||
def state: State|Null =
|
||||
_status.lastOption match
|
||||
case Some(x) => x
|
||||
case None => null
|
||||
|
||||
def status: List[State] =
|
||||
_status.toList
|
||||
|
||||
def provide (i: Any): Unit =
|
||||
variables += (i.getClass -> i)
|
||||
@ -28,6 +51,11 @@ class EventEnv (
|
||||
case None => ConsumeResult(false)
|
||||
}
|
||||
|
||||
def consume [T: ClassTag] (consumer: T => Unit): ConsumeResult =
|
||||
variables get classTag[T].runtimeClass match
|
||||
case Some(i) => consumer(i.asInstanceOf[T]); ConsumeResult(true)
|
||||
case None => ConsumeResult(false)
|
||||
|
||||
class ConsumeResult (success: Boolean) {
|
||||
def onfail (processor: => Unit): Unit = {
|
||||
if !success then processor
|
||||
|
@ -2,6 +2,32 @@ package cc.sukazyo.cono.morny.bot.api
|
||||
|
||||
trait EventListener () {
|
||||
|
||||
/** Determine if this event listener should be processed.
|
||||
*
|
||||
* Default implementation is it only be [[true]] when the event
|
||||
* is not ok yet (when [[EventEnv.isEventOk]] is false).
|
||||
*
|
||||
* Notice that: You should not override this method to filter some
|
||||
* affair level conditions (such as if this update contains a text
|
||||
* message), you should write them to the listener function! This
|
||||
* method is just for event low-level controls.
|
||||
*
|
||||
* @param env The [[EventEnv event variable]].
|
||||
* @return [[true]] if this event listener should run; [[false]]
|
||||
* if it should not run.
|
||||
*/
|
||||
def executeFilter (using env: EventEnv): Boolean =
|
||||
if env.state == null then true else false
|
||||
|
||||
/** Run at all event listeners' listen methods done.
|
||||
*
|
||||
* Listen methods is the methods defined in [[EventListener this]]
|
||||
* trait starts with `on`.
|
||||
*
|
||||
* This method will always run no matter the result of [[executeFilter]]
|
||||
*/
|
||||
def atEventPost (using EventEnv): Unit = {}
|
||||
|
||||
def onMessage (using EventEnv): Unit = {}
|
||||
def onEditedMessage (using EventEnv): Unit = {}
|
||||
def onChannelPost (using EventEnv): Unit = {}
|
||||
|
@ -9,7 +9,6 @@ import com.pengrad.telegrambot.UpdatesListener
|
||||
|
||||
import scala.collection.mutable
|
||||
import scala.language.postfixOps
|
||||
import scala.util.boundary
|
||||
|
||||
/** Contains a [[mutable.Queue]] of [[EventListener]], and delivery telegram [[Update]].
|
||||
*
|
||||
@ -30,53 +29,66 @@ class EventListenerManager (using coeur: MornyCoeur) extends UpdatesListener {
|
||||
this setName s"upd-${update.updateId()}-$t"
|
||||
|
||||
override def run (): Unit = {
|
||||
|
||||
given env: EventEnv = EventEnv(update)
|
||||
boundary { for (i <- listeners) {
|
||||
try {
|
||||
updateThreadName("message")
|
||||
if update.message ne null then i.onMessage
|
||||
updateThreadName("edited-message")
|
||||
if update.editedMessage ne null then i.onEditedMessage
|
||||
updateThreadName("channel-post")
|
||||
if update.channelPost ne null then i.onChannelPost
|
||||
updateThreadName("edited-channel-post")
|
||||
if update.editedChannelPost ne null then i.onEditedChannelPost
|
||||
updateThreadName("inline-query")
|
||||
if update.inlineQuery ne null then i.onInlineQuery
|
||||
updateThreadName("chosen-inline-result")
|
||||
if update.chosenInlineResult ne null then i.onChosenInlineResult
|
||||
updateThreadName("callback-query")
|
||||
if update.callbackQuery ne null then i.onCallbackQuery
|
||||
updateThreadName("shipping-query")
|
||||
if update.shippingQuery ne null then i.onShippingQuery
|
||||
updateThreadName("pre-checkout-query")
|
||||
if update.preCheckoutQuery ne null then i.onPreCheckoutQuery
|
||||
updateThreadName("poll")
|
||||
if update.poll ne null then i.onPoll
|
||||
updateThreadName("poll-answer")
|
||||
if update.pollAnswer ne null then i.onPollAnswer
|
||||
updateThreadName("my-chat-member")
|
||||
if update.myChatMember ne null then i.onMyChatMemberUpdated
|
||||
updateThreadName("chat-member")
|
||||
if update.chatMember ne null then i.onChatMemberUpdated
|
||||
updateThreadName("chat-join-request")
|
||||
if update.chatJoinRequest ne null then i.onChatJoinRequest
|
||||
} catch case e => {
|
||||
val errorMessage = StringBuilder()
|
||||
errorMessage ++= "Event throws unexpected exception:\n"
|
||||
errorMessage ++= (exceptionLog(e) indent 4)
|
||||
e match
|
||||
case actionFailed: EventRuntimeException.ActionFailed =>
|
||||
errorMessage ++= "\ntg-api action: response track: "
|
||||
errorMessage ++= (GsonBuilder().setPrettyPrinting().create().toJson(
|
||||
actionFailed.response
|
||||
) indent 4) ++= "\n"
|
||||
case _ =>
|
||||
logger error errorMessage.toString
|
||||
coeur.daemons.reporter.exception(e, "on event running")
|
||||
}
|
||||
if env.isEventOk then boundary.break()
|
||||
}}
|
||||
|
||||
for (i <- listeners)
|
||||
if (i.executeFilter)
|
||||
runEventListener(i)
|
||||
for (i <- listeners)
|
||||
runEventPost(i)
|
||||
|
||||
}
|
||||
|
||||
private def runEventPost (i: EventListener)(using EventEnv): Unit = {
|
||||
updateThreadName("#post")
|
||||
i.atEventPost
|
||||
}
|
||||
|
||||
private def runEventListener (i: EventListener)(using EventEnv): Unit = {
|
||||
try {
|
||||
updateThreadName("message")
|
||||
if update.message ne null then i.onMessage
|
||||
updateThreadName("edited-message")
|
||||
if update.editedMessage ne null then i.onEditedMessage
|
||||
updateThreadName("channel-post")
|
||||
if update.channelPost ne null then i.onChannelPost
|
||||
updateThreadName("edited-channel-post")
|
||||
if update.editedChannelPost ne null then i.onEditedChannelPost
|
||||
updateThreadName("inline-query")
|
||||
if update.inlineQuery ne null then i.onInlineQuery
|
||||
updateThreadName("chosen-inline-result")
|
||||
if update.chosenInlineResult ne null then i.onChosenInlineResult
|
||||
updateThreadName("callback-query")
|
||||
if update.callbackQuery ne null then i.onCallbackQuery
|
||||
updateThreadName("shipping-query")
|
||||
if update.shippingQuery ne null then i.onShippingQuery
|
||||
updateThreadName("pre-checkout-query")
|
||||
if update.preCheckoutQuery ne null then i.onPreCheckoutQuery
|
||||
updateThreadName("poll")
|
||||
if update.poll ne null then i.onPoll
|
||||
updateThreadName("poll-answer")
|
||||
if update.pollAnswer ne null then i.onPollAnswer
|
||||
updateThreadName("my-chat-member")
|
||||
if update.myChatMember ne null then i.onMyChatMemberUpdated
|
||||
updateThreadName("chat-member")
|
||||
if update.chatMember ne null then i.onChatMemberUpdated
|
||||
updateThreadName("chat-join-request")
|
||||
if update.chatJoinRequest ne null then i.onChatJoinRequest
|
||||
} catch case e => {
|
||||
val errorMessage = StringBuilder()
|
||||
errorMessage ++= "Event throws unexpected exception:\n"
|
||||
errorMessage ++= (exceptionLog(e) indent 4)
|
||||
e match
|
||||
case actionFailed: EventRuntimeException.ActionFailed =>
|
||||
errorMessage ++= "\ntg-api action: response track: "
|
||||
errorMessage ++= (GsonBuilder().setPrettyPrinting().create().toJson(
|
||||
actionFailed.response
|
||||
) indent 4) ++= "\n"
|
||||
case _ =>
|
||||
logger error errorMessage.toString
|
||||
coeur.daemons.reporter.exception(e, "on event running")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -196,6 +196,7 @@ class Encryptor (using coeur: MornyCoeur) extends ITelegramCommand {
|
||||
import cc.sukazyo.cono.morny.util.tgapi.formatting.TelegramParseEscape.escapeHtml as h
|
||||
coeur.account exec SendMessage(
|
||||
event.message.chat.id,
|
||||
// language=html
|
||||
s"<pre><code>${h(_text.text)}</code></pre>"
|
||||
).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
|
||||
|
||||
import cc.sukazyo.cono.morny.MornyCoeur
|
||||
import cc.sukazyo.cono.morny.data.ip186.IP186QueryHandler
|
||||
import cc.sukazyo.cono.morny.extra.ip186.IP186QueryHandler
|
||||
import cc.sukazyo.cono.morny.util.tgapi.InputCommand
|
||||
import cc.sukazyo.cono.morny.util.tgapi.TelegramExtensions.Bot.exec
|
||||
import com.pengrad.telegrambot.model.Update
|
||||
|
@ -43,11 +43,13 @@ class MornyCommands (using coeur: MornyCoeur) {
|
||||
$IP186Query.IP,
|
||||
$IP186Query.Whois,
|
||||
Encryptor(),
|
||||
MornyOldJrrp(),
|
||||
GetSocial(),
|
||||
|
||||
$MornyManagers.SaveData,
|
||||
$MornyInformation,
|
||||
$MornyInformationOlds.Version,
|
||||
$MornyInformationOlds.Runtime,
|
||||
MornyOldJrrp(),
|
||||
$MornyManagers.Exit,
|
||||
|
||||
Testing(),
|
||||
|
@ -22,11 +22,13 @@ class MornyInformation (using coeur: MornyCoeur) extends ITelegramCommand {
|
||||
val RUNTIME = "runtime"
|
||||
val VERSION = "version"
|
||||
val VERSION_2 = "v"
|
||||
val TASKS = "tasks"
|
||||
val EVENTS = "event"
|
||||
}
|
||||
|
||||
override val name: String = "info"
|
||||
override val aliases: Array[ICommandAlias]|Null = null
|
||||
override val paramRule: String = "[(version|runtime|stickers[.IDs])]"
|
||||
override val paramRule: String = "[(version|runtime|stickers[.IDs]|tasks|event)]"
|
||||
override val description: String = "输出当前 Morny 的各种信息"
|
||||
|
||||
override def execute (using command: InputCommand, event: Update): Unit = {
|
||||
@ -42,6 +44,8 @@ class MornyInformation (using coeur: MornyCoeur) extends ITelegramCommand {
|
||||
case s if s startsWith Subs.STICKERS => echoStickers
|
||||
case Subs.RUNTIME => echoRuntime
|
||||
case Subs.VERSION | Subs.VERSION_2 => echoVersion
|
||||
case Subs.TASKS => echoTasksStatus
|
||||
case Subs.EVENTS => echoEventStatistics
|
||||
case _ => echo404
|
||||
}
|
||||
|
||||
@ -144,6 +148,29 @@ class MornyInformation (using coeur: MornyCoeur) extends ITelegramCommand {
|
||||
).parseMode(ParseMode HTML).replyToMessageId(event.message.messageId)
|
||||
}
|
||||
|
||||
private def echoTasksStatus (using update: Update): Unit = {
|
||||
// if !coeur.trusted.isTrusted(update.message.from.id) then return;
|
||||
coeur.account exec SendMessage(
|
||||
update.message.chat.id,
|
||||
// language=html
|
||||
s"""<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 =
|
||||
coeur.account exec new SendSticker(
|
||||
event.message.chat.id,
|
||||
|
@ -1,7 +1,8 @@
|
||||
package cc.sukazyo.cono.morny.bot.command
|
||||
|
||||
import cc.sukazyo.cono.morny.MornyCoeur
|
||||
import cc.sukazyo.cono.morny.data.{NbnhhshQuery, TelegramStickers}
|
||||
import cc.sukazyo.cono.morny.data.TelegramStickers
|
||||
import cc.sukazyo.cono.morny.extra.NbnhhshQuery
|
||||
import cc.sukazyo.cono.morny.util.tgapi.formatting.TelegramParseEscape.escapeHtml as h
|
||||
import cc.sukazyo.cono.morny.util.tgapi.InputCommand
|
||||
import cc.sukazyo.cono.morny.util.tgapi.TelegramExtensions.Bot.exec
|
||||
|
@ -17,6 +17,7 @@ class MornyEventListeners (using manager: EventListenerManager) (using coeur: Mo
|
||||
OnUserSlashAction(),
|
||||
OnCallMe(),
|
||||
OnCallMsgSend(),
|
||||
OnGetSocial(),
|
||||
OnMedicationNotifyApply(),
|
||||
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.MornyCoeur
|
||||
import com.pengrad.telegrambot.model.Update
|
||||
|
||||
class MornyOnUpdateTimestampOffsetLock (using coeur: MornyCoeur) extends EventListener {
|
||||
|
||||
private def checkOutdated (timestamp: Int)(using event: EventEnv): Unit =
|
||||
if coeur.config.eventIgnoreOutdated && (timestamp < (coeur.coeurStartTimestamp/1000)) then
|
||||
event.setEventOk
|
||||
event.setEventCanceled
|
||||
|
||||
override def onMessage (using event: EventEnv): Unit = checkOutdated(event.update.message.date)
|
||||
override def onEditedMessage (using event: EventEnv): Unit = checkOutdated(event.update.editedMessage.date)
|
||||
|
@ -5,7 +5,7 @@ import cc.sukazyo.cono.morny.bot.api.{EventEnv, EventListener}
|
||||
import cc.sukazyo.cono.morny.data.TelegramStickers
|
||||
import cc.sukazyo.cono.morny.util.tgapi.formatting.TelegramFormatter.*
|
||||
import cc.sukazyo.cono.morny.util.tgapi.TelegramExtensions.Bot.exec
|
||||
import com.pengrad.telegrambot.model.{Chat, Message, Update, User}
|
||||
import com.pengrad.telegrambot.model.{Chat, Message, User}
|
||||
import com.pengrad.telegrambot.model.request.ParseMode
|
||||
import com.pengrad.telegrambot.request.{ForwardMessage, GetChat, SendMessage, SendSticker}
|
||||
|
||||
@ -73,13 +73,14 @@ class OnCallMe (using coeur: MornyCoeur) extends EventListener {
|
||||
lastDinnerData.forwardFromMessageId
|
||||
)
|
||||
import cc.sukazyo.cono.morny.util.CommonFormat.{formatDate, formatDuration}
|
||||
import cc.sukazyo.cono.morny.util.EpochDateTime.EpochMillis
|
||||
import cc.sukazyo.cono.morny.util.tgapi.formatting.TelegramParseEscape.escapeHtml as h
|
||||
def lastDinner_dateMillis: Long = lastDinnerData.forwardDate longValue;
|
||||
def lastDinner_dateMillis: EpochMillis = EpochMillis fromEpochSeconds lastDinnerData.forwardDate
|
||||
coeur.account exec SendMessage(
|
||||
req.from.id,
|
||||
"<i>on</i> <code>%s [UTC+8]</code>\n- <code>%s</code> <i>before</i>".formatted(
|
||||
h(formatDate(lastDinner_dateMillis, 8)),
|
||||
h(formatDuration(lastDinner_dateMillis))
|
||||
h(formatDuration(System.currentTimeMillis - lastDinner_dateMillis))
|
||||
)
|
||||
).parseMode(ParseMode HTML).replyToMessageId(sendResp.message.messageId)
|
||||
isAllowed = true
|
||||
|
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.MornyCoeur
|
||||
import cc.sukazyo.cono.morny.daemon.{MedicationTimer, MornyDaemons}
|
||||
import com.pengrad.telegrambot.model.{Message, Update}
|
||||
import com.pengrad.telegrambot.model.Message
|
||||
|
||||
class OnMedicationNotifyApply (using coeur: MornyCoeur) extends EventListener {
|
||||
|
||||
@ -14,8 +13,8 @@ class OnMedicationNotifyApply (using coeur: MornyCoeur) extends EventListener {
|
||||
|
||||
private def editedMessageProcess (edited: Message)(using event: EventEnv): Unit = {
|
||||
if edited.chat.id != coeur.config.medicationNotifyToChat then return;
|
||||
coeur.daemons.medicationTimer.refreshNotificationWrite(edited)
|
||||
event.setEventOk
|
||||
if coeur.daemons.medicationTimer.refreshNotificationWrite(edited) then
|
||||
event.setEventOk
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -4,7 +4,6 @@ import cc.sukazyo.cono.morny.bot.api.{EventEnv, EventListener}
|
||||
import cc.sukazyo.cono.morny.MornyCoeur
|
||||
import cc.sukazyo.cono.morny.bot.event.OnQuestionMarkReply.isAllMessageMark
|
||||
import cc.sukazyo.cono.morny.util.tgapi.TelegramExtensions.Bot.exec
|
||||
import com.pengrad.telegrambot.model.Update
|
||||
import com.pengrad.telegrambot.request.SendMessage
|
||||
|
||||
import scala.language.postfixOps
|
||||
@ -33,7 +32,9 @@ class OnQuestionMarkReply (using coeur: MornyCoeur) extends EventListener {
|
||||
|
||||
object OnQuestionMarkReply {
|
||||
|
||||
private val QUESTION_MARKS = Set('?', '?', '¿', '⁈', '⁇', '‽', '❔', '❓')
|
||||
// todo: due to the limitation of Java char, the ⁉️ character (actually not a
|
||||
// single character) is not supported yet.
|
||||
private val QUESTION_MARKS = Set('?', '?', '¿', '⁈', '⁇', '‽', '⸘', '❔', '❓')
|
||||
|
||||
def isAllMessageMark (using text: String): Boolean = {
|
||||
boundary[Boolean] {
|
||||
|
@ -4,13 +4,12 @@ import cc.sukazyo.cono.morny.bot.api.{EventEnv, EventListener}
|
||||
import cc.sukazyo.cono.morny.bot.command.MornyCommands
|
||||
import cc.sukazyo.cono.morny.util.tgapi.InputCommand
|
||||
import cc.sukazyo.cono.morny.Log.logger
|
||||
import cc.sukazyo.cono.morny.MornyCoeur
|
||||
|
||||
class OnUniMeowTrigger (using commands: MornyCommands) (using coeur: MornyCoeur) extends EventListener {
|
||||
class OnUniMeowTrigger (using commands: MornyCommands) extends EventListener {
|
||||
|
||||
override def onMessage (using event: EventEnv): Unit = {
|
||||
|
||||
event.consume (classOf[InputCommand]) { input =>
|
||||
event.consume[InputCommand] { input =>
|
||||
logger trace s"got input command {$input} from event-context"
|
||||
|
||||
for ((name, command_instance) <- commands.commands_uni) {
|
||||
|
@ -12,7 +12,8 @@ class MornyQueries (using MornyCoeur) {
|
||||
RawText(),
|
||||
MyInformation(),
|
||||
ShareToolTwitter(),
|
||||
ShareToolBilibili()
|
||||
ShareToolBilibili(),
|
||||
ShareToolSocialContent()
|
||||
)
|
||||
|
||||
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 isBlank) return null
|
||||
|
||||
import cc.sukazyo.cono.morny.data.BilibiliForms.*
|
||||
import cc.sukazyo.cono.morny.extra.BilibiliForms.*
|
||||
val result: BiliVideoId =
|
||||
try
|
||||
parse_videoUrl(event.inlineQuery.query)
|
||||
|
@ -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
|
||||
|
||||
import cc.sukazyo.cono.morny.extra.twitter
|
||||
import cc.sukazyo.cono.morny.extra.twitter.TweetUrlInformation
|
||||
import cc.sukazyo.cono.morny.util.tgapi.formatting.NamingUtils.inlineQueryId
|
||||
import com.pengrad.telegrambot.model.Update
|
||||
import com.pengrad.telegrambot.model.request.InlineQueryResultArticle
|
||||
@ -10,26 +12,25 @@ import scala.util.matching.Regex
|
||||
class ShareToolTwitter extends ITelegramQuery {
|
||||
|
||||
private val TITLE_VX = "[tweet] Share as VxTwitter"
|
||||
private val TITLE_VX_COMBINED = "[tweet] Share as VxTwitter(combination)"
|
||||
private val ID_PREFIX_VX = "[morny/share/twitter/vxtwi]"
|
||||
private val ID_PREFIX_VX_COMBINED = "[morny/share/twitter/vxtwi_combine]"
|
||||
private val REGEX_TWEET_LINK: Regex = "^(?:https?://)?((?:(?:c\\.)?vx|fx|www\\.)?twitter\\.com)/((\\w+)/status/(\\d+)(?:/photo/(\\d+))?)/?(\\?[\\w&=-]+)?$"r
|
||||
private val TITLE_FX = "[tweet] Share as Fix-Tweet"
|
||||
private val ID_PREFIX_FX = "[morny/share/twitter/fxtwi]"
|
||||
|
||||
override def query (event: Update): List[InlineQueryUnit[_]] | Null = {
|
||||
|
||||
if (event.inlineQuery.query == null) return null
|
||||
|
||||
event.inlineQuery.query match
|
||||
twitter.parseTweetUrl(event.inlineQuery.query) match
|
||||
|
||||
case REGEX_TWEET_LINK(_, _path_data, _, _, _, _) =>
|
||||
case Some(TweetUrlInformation(_, _path_data, _, _, _, _)) =>
|
||||
List(
|
||||
InlineQueryUnit(InlineQueryResultArticle(
|
||||
inlineQueryId(ID_PREFIX_FX + event.inlineQuery.query), TITLE_FX,
|
||||
s"https://fxtwitter.com/$_path_data"
|
||||
)),
|
||||
InlineQueryUnit(InlineQueryResultArticle(
|
||||
inlineQueryId(ID_PREFIX_VX+event.inlineQuery.query), TITLE_VX,
|
||||
s"https://vxtwitter.com/$_path_data"
|
||||
)),
|
||||
InlineQueryUnit(InlineQueryResultArticle(
|
||||
inlineQueryId(ID_PREFIX_VX_COMBINED+event.inlineQuery.query), TITLE_VX_COMBINED,
|
||||
s"https://c.vxtwitter.com/$_path_data"
|
||||
))
|
||||
)
|
||||
|
||||
|
@ -42,7 +42,7 @@ class EventHacker (using coeur: MornyCoeur) {
|
||||
coeur.account exec SendMessage(
|
||||
x.from_chat,
|
||||
// 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)
|
||||
true
|
||||
}
|
||||
|
@ -1,19 +1,24 @@
|
||||
package cc.sukazyo.cono.morny.daemon
|
||||
|
||||
import cc.sukazyo.cono.morny.Log.{exceptionLog, logger}
|
||||
import cc.sukazyo.cono.morny.Log.logger
|
||||
import cc.sukazyo.cono.morny.MornyCoeur
|
||||
import cc.sukazyo.cono.morny.daemon.MedicationTimer.calcNextRoutineTimestamp
|
||||
import cc.sukazyo.cono.morny.util.schedule.RoutineTask
|
||||
import cc.sukazyo.cono.morny.util.tgapi.TelegramExtensions.Bot.exec
|
||||
import cc.sukazyo.cono.morny.util.CommonFormat
|
||||
import cc.sukazyo.cono.morny.util.EpochDateTime.EpochMillis
|
||||
import com.cronutils.builder.CronBuilder
|
||||
import com.cronutils.model.definition.{CronDefinition, CronDefinitionBuilder}
|
||||
import com.cronutils.model.time.ExecutionTime
|
||||
import com.pengrad.telegrambot.model.{Message, MessageEntity}
|
||||
import com.pengrad.telegrambot.request.{EditMessageText, SendMessage}
|
||||
import com.pengrad.telegrambot.response.SendResponse
|
||||
|
||||
import java.time.{LocalDateTime, ZoneOffset}
|
||||
import java.time.{Instant, ZonedDateTime, ZoneOffset}
|
||||
import scala.collection.mutable.ArrayBuffer
|
||||
import scala.language.implicitConversions
|
||||
|
||||
class MedicationTimer (using coeur: MornyCoeur) extends Thread {
|
||||
class MedicationTimer (using coeur: MornyCoeur) {
|
||||
|
||||
private val NOTIFY_MESSAGE = "🍥⏲"
|
||||
private val DAEMON_THREAD_NAME_DEF = "MedicationTimer"
|
||||
@ -23,53 +28,49 @@ class MedicationTimer (using coeur: MornyCoeur) extends Thread {
|
||||
private val notify_atHour: Set[Int] = coeur.config.medicationNotifyAt.asScala.toSet.map(_.intValue)
|
||||
private val notify_toChat = coeur.config.medicationNotifyToChat
|
||||
|
||||
this.setName(DAEMON_THREAD_NAME_DEF)
|
||||
|
||||
private var lastNotify_messageId: Option[Int] = None
|
||||
|
||||
override def run (): Unit = {
|
||||
private val scheduleTask: RoutineTask = new RoutineTask {
|
||||
|
||||
if ((notify_toChat == -1) || (notify_atHour isEmpty)) {
|
||||
logger notice "Medication Timer disabled : related param is not complete set"
|
||||
return
|
||||
}
|
||||
override def name: String = DAEMON_THREAD_NAME_DEF
|
||||
|
||||
logger notice "Medication Timer started."
|
||||
while (!this.isInterrupted) {
|
||||
try {
|
||||
val next_time = calcNextRoutineTimestamp(System.currentTimeMillis, use_timeZone, notify_atHour)
|
||||
logger info s"medication timer will send next notify at ${CommonFormat.formatDate(next_time, use_timeZone.getTotalSeconds/60/60)} with $use_timeZone [$next_time]"
|
||||
val sleep_millis = next_time - System.currentTimeMillis
|
||||
logger debug s"medication timer will sleep ${CommonFormat.formatDuration(sleep_millis)} [$sleep_millis]"
|
||||
Thread sleep sleep_millis
|
||||
sendNotification()
|
||||
logger info "medication notify sent."
|
||||
} catch
|
||||
case _: InterruptedException =>
|
||||
interrupt()
|
||||
logger notice "MedicationTimer was interrupted, will be exit now"
|
||||
case ill: IllegalArgumentException =>
|
||||
logger warn "MedicationTimer will not work due to: " + ill.getMessage
|
||||
interrupt()
|
||||
case e =>
|
||||
logger error
|
||||
s"""unexpected error occurred on NotificationTimer
|
||||
|${exceptionLog(e)}"""
|
||||
.stripMargin
|
||||
coeur.daemons.reporter.exception(e)
|
||||
def calcNextSendTime: EpochMillis =
|
||||
val next_time = calcNextRoutineTimestamp(System.currentTimeMillis, use_timeZone, notify_atHour)
|
||||
logger info s"medication timer will send next notify at ${CommonFormat.formatDate(next_time, use_timeZone.getTotalSeconds / 60 / 60)} with $use_timeZone [$next_time]"
|
||||
next_time
|
||||
|
||||
override def firstRoutineTimeMillis: EpochMillis =
|
||||
calcNextSendTime
|
||||
|
||||
override def nextRoutineTimeMillis (previousRoutineScheduledTimeMillis: EpochMillis): EpochMillis | Null =
|
||||
calcNextSendTime
|
||||
|
||||
override def main: Unit = {
|
||||
sendNotification()
|
||||
logger info "medication notify sent."
|
||||
}
|
||||
logger notice "Medication Timer stopped."
|
||||
|
||||
}
|
||||
|
||||
def start(): Unit =
|
||||
if ((notify_toChat == -1) || (notify_atHour isEmpty))
|
||||
logger notice "Medication Timer disabled : related param is not complete set"
|
||||
return;
|
||||
coeur.tasks ++ scheduleTask
|
||||
logger notice "Medication Timer started."
|
||||
|
||||
def stop(): Unit =
|
||||
coeur.tasks % scheduleTask
|
||||
logger notice "Medication Timer stopped."
|
||||
|
||||
private def sendNotification(): Unit = {
|
||||
val sendResponse: SendResponse = coeur.account exec SendMessage(notify_toChat, NOTIFY_MESSAGE)
|
||||
if sendResponse isOk then lastNotify_messageId = Some(sendResponse.message.messageId)
|
||||
else lastNotify_messageId = None
|
||||
}
|
||||
|
||||
def refreshNotificationWrite (edited: Message): Unit = {
|
||||
if (lastNotify_messageId isEmpty) || (lastNotify_messageId.get != (edited.messageId toInt)) then return
|
||||
def refreshNotificationWrite (edited: Message): Boolean = {
|
||||
if (lastNotify_messageId isEmpty) || (lastNotify_messageId.get != (edited.messageId toInt)) then return false
|
||||
import cc.sukazyo.cono.morny.util.CommonFormat.formatDate
|
||||
val editTime = formatDate(edited.editDate*1000, use_timeZone.getTotalSeconds/60/60)
|
||||
val entities = ArrayBuffer.empty[MessageEntity]
|
||||
@ -81,24 +82,31 @@ class MedicationTimer (using coeur: MornyCoeur) extends Thread {
|
||||
edited.text + s"\n-- $editTime --"
|
||||
).entities(entities toArray:_*)
|
||||
lastNotify_messageId = None
|
||||
true
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
object MedicationTimer {
|
||||
|
||||
//noinspection ScalaWeakerAccess
|
||||
val cronDef: CronDefinition = CronDefinitionBuilder.defineCron
|
||||
.withHours.and
|
||||
.instance
|
||||
|
||||
@throws[IllegalArgumentException]
|
||||
def calcNextRoutineTimestamp (baseTimeMillis: Long, zone: ZoneOffset, notifyAt: Set[Int]): Long = {
|
||||
def calcNextRoutineTimestamp (baseTimeMillis: EpochMillis, zone: ZoneOffset, notifyAt: Set[Int]): EpochMillis = {
|
||||
if (notifyAt isEmpty) throw new IllegalArgumentException("notify time is not set")
|
||||
var time = LocalDateTime.ofEpochSecond(
|
||||
baseTimeMillis / 1000, ((baseTimeMillis % 1000) * 1000 * 1000) toInt,
|
||||
zone
|
||||
).withMinute(0).withSecond(0).withNano(0)
|
||||
time = time plusHours 1
|
||||
while (!(notifyAt contains(time getHour))) {
|
||||
time = time plusHours 1
|
||||
}
|
||||
(time toInstant zone) toEpochMilli
|
||||
import com.cronutils.model.field.expression.FieldExpressionFactory.*
|
||||
ExecutionTime.forCron(CronBuilder.cron(cronDef)
|
||||
.withHour(and({
|
||||
import scala.jdk.CollectionConverters.*
|
||||
(for (i <- notifyAt) yield on(i)).toList.asJava
|
||||
}))
|
||||
.instance
|
||||
).nextExecution(
|
||||
ZonedDateTime ofInstant (Instant ofEpochMilli baseTimeMillis, zone.normalized)
|
||||
).get.toInstant.toEpochMilli
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -13,8 +13,8 @@ class MornyDaemons (using val coeur: MornyCoeur) {
|
||||
|
||||
logger notice "ALL Morny Daemons starting..."
|
||||
|
||||
// TrackerDataManager.init();
|
||||
medicationTimer.start()
|
||||
reporter.start()
|
||||
|
||||
logger notice "Morny Daemons started."
|
||||
|
||||
@ -24,12 +24,8 @@ class MornyDaemons (using val coeur: MornyCoeur) {
|
||||
|
||||
logger notice "stopping All Morny Daemons..."
|
||||
|
||||
// TrackerDataManager.DAEMON.interrupt();
|
||||
medicationTimer.interrupt()
|
||||
// TrackerDataManager.trackingLock.lock();
|
||||
try { medicationTimer.join() }
|
||||
catch case e: InterruptedException =>
|
||||
e.printStackTrace(System.out)
|
||||
medicationTimer.stop()
|
||||
reporter.stop()
|
||||
|
||||
logger notice "stopped ALL Morny Daemons."
|
||||
}
|
||||
|
@ -2,16 +2,26 @@ package cc.sukazyo.cono.morny.daemon
|
||||
|
||||
import cc.sukazyo.cono.morny.{MornyCoeur, MornyConfig}
|
||||
import cc.sukazyo.cono.morny.Log.{exceptionLog, logger}
|
||||
import cc.sukazyo.cono.morny.bot.api.{EventEnv, EventListener}
|
||||
import cc.sukazyo.cono.morny.data.MornyInformation.getVersionAllFullTagHTML
|
||||
import cc.sukazyo.cono.morny.util.statistics.NumericStatistics
|
||||
import cc.sukazyo.cono.morny.util.tgapi.event.EventRuntimeException
|
||||
import cc.sukazyo.cono.morny.util.tgapi.formatting.TelegramFormatter.*
|
||||
import cc.sukazyo.cono.morny.util.tgapi.formatting.TelegramParseEscape.escapeHtml as h
|
||||
import cc.sukazyo.cono.morny.util.tgapi.TelegramExtensions.Bot.exec
|
||||
import cc.sukazyo.cono.morny.util.EpochDateTime.DurationMillis
|
||||
import cc.sukazyo.cono.morny.util.schedule.CronTask
|
||||
import com.cronutils.builder.CronBuilder
|
||||
import com.cronutils.model.Cron
|
||||
import com.cronutils.model.definition.CronDefinitionBuilder
|
||||
import com.google.gson.GsonBuilder
|
||||
import com.pengrad.telegrambot.model.request.ParseMode
|
||||
import com.pengrad.telegrambot.model.User
|
||||
import com.pengrad.telegrambot.request.{BaseRequest, SendMessage}
|
||||
import com.pengrad.telegrambot.response.BaseResponse
|
||||
import com.pengrad.telegrambot.TelegramException
|
||||
|
||||
import java.time.ZoneId
|
||||
|
||||
class MornyReport (using coeur: MornyCoeur) {
|
||||
|
||||
@ -23,13 +33,20 @@ class MornyReport (using coeur: MornyCoeur) {
|
||||
if !enabled then return;
|
||||
try {
|
||||
coeur.account exec report
|
||||
} catch case e: EventRuntimeException.ActionFailed => {
|
||||
logger warn
|
||||
s"""cannot execute report to telegram:
|
||||
|${exceptionLog(e) indent 4}
|
||||
| tg-api response:
|
||||
|${(e.response toString) indent 4}"""
|
||||
.stripMargin
|
||||
} catch case e: EventRuntimeException => {
|
||||
import EventRuntimeException.*
|
||||
e match
|
||||
case e: ActionFailed =>
|
||||
logger warn
|
||||
s"""cannot execute report to telegram:
|
||||
|${exceptionLog(e) indent 4}
|
||||
| tg-api response:
|
||||
|${(e.response toString) indent 4}""".stripMargin
|
||||
case e: ClientFailed =>
|
||||
logger error
|
||||
s"""failed when report to telegram:
|
||||
|${exceptionLog(e.getCause) indent 4}
|
||||
|""".stripMargin
|
||||
}
|
||||
}
|
||||
|
||||
@ -37,15 +54,19 @@ class MornyReport (using coeur: MornyCoeur) {
|
||||
def _tgErrFormat: String = e match
|
||||
case api: EventRuntimeException.ActionFailed =>
|
||||
// language=html
|
||||
"\n\ntg-api error:\n<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))
|
||||
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 _ => ""
|
||||
executeReport(SendMessage(
|
||||
coeur.config.reportToChat,
|
||||
// language=html
|
||||
s"""<b>▌Coeur Unexpected Exception </b>
|
||||
|${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
|
||||
).parseMode(ParseMode HTML))
|
||||
}
|
||||
@ -67,10 +88,12 @@ class MornyReport (using coeur: MornyCoeur) {
|
||||
// language=html
|
||||
s"""<b>▌Morny Logged in</b>
|
||||
|-v $getVersionAllFullTagHTML
|
||||
|as user @${coeur.username}
|
||||
|Logged into user: @${coeur.username}
|
||||
|
|
||||
|as config fields:
|
||||
|${sectionConfigFields(coeur.config)}"""
|
||||
|${sectionConfigFields(coeur.config)}
|
||||
|
|
||||
|Report Daemon will use TimeZone <code>${coeur.config.reportZone.getDisplayName}</code> for following report."""
|
||||
.stripMargin
|
||||
).parseMode(ParseMode HTML))
|
||||
}
|
||||
@ -120,4 +143,106 @@ class MornyReport (using coeur: MornyCoeur) {
|
||||
).parseMode(ParseMode HTML))
|
||||
}
|
||||
|
||||
object EventStatistics {
|
||||
|
||||
private var eventTotal = 0
|
||||
private var eventCanceled = 0
|
||||
private val runningTime: NumericStatistics[DurationMillis] = NumericStatistics()
|
||||
|
||||
def reset (): Unit = {
|
||||
eventTotal = 0
|
||||
eventCanceled = 0
|
||||
runningTime.reset()
|
||||
}
|
||||
|
||||
private def runningTimeStatisticsHTML: String =
|
||||
runningTime.value match
|
||||
// language=html
|
||||
case None => "<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
|
||||
|
||||
import cc.sukazyo.cono.morny.util.EpochDateTime.{EpochDays, EpochMillis}
|
||||
import com.pengrad.telegrambot.model.User
|
||||
|
||||
import scala.language.postfixOps
|
||||
|
||||
object MornyJrrp {
|
||||
|
||||
def jrrp_of_telegramUser (user: User, timestamp: Long): Double =
|
||||
jrrp_v_xmomi(user.id, timestamp/(1000*60*60*24)) * 100.0
|
||||
def jrrp_of_telegramUser (user: User, timestamp: EpochMillis): Double =
|
||||
jrrp_v_xmomi(user.id, EpochDays fromEpochMillis timestamp) * 100.0
|
||||
|
||||
private def jrrp_v_xmomi (identifier: Long, dayStamp: Long): Double =
|
||||
private def jrrp_v_xmomi (identifier: Long, dayStamp: EpochDays): Double =
|
||||
import cc.sukazyo.cono.morny.util.CommonEncrypt.MD5
|
||||
import cc.sukazyo.cono.morny.util.ConvertByteHex.toHex
|
||||
java.lang.Long.parseLong(MD5(s"$identifier@$dayStamp").toHex.substring(0, 4), 16) / (0xffff toDouble)
|
||||
|
@ -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.SttpPublic.Schemes
|
||||
import cc.sukazyo.cono.morny.util.SttpPublic.{mornyBasicRequest, Schemes}
|
||||
import cc.sukazyo.cono.morny.util.UseSelect.select
|
||||
import sttp.client3.{basicRequest, ignore, HttpError, SttpClientException}
|
||||
import sttp.client3.{HttpError, SttpClientException}
|
||||
import sttp.client3.okhttp.OkHttpSyncBackend
|
||||
import sttp.model.Uri
|
||||
|
||||
@ -77,7 +77,8 @@ object BilibiliForms {
|
||||
throw IllegalArgumentException(s"is a b23 video link: $uri . (use parse_videoUrl instead)")
|
||||
|
||||
try {
|
||||
val response = basicRequest
|
||||
import sttp.client3.ignore
|
||||
val response = mornyBasicRequest
|
||||
.get(uri)
|
||||
.followRedirects(false)
|
||||
.response(ignore)
|
@ -1,7 +1,8 @@
|
||||
package cc.sukazyo.cono.morny.data
|
||||
package cc.sukazyo.cono.morny.extra
|
||||
|
||||
import cc.sukazyo.cono.morny.util.SttpPublic.mornyBasicRequest
|
||||
import com.google.gson.Gson
|
||||
import sttp.client3.{asString, basicRequest, HttpError, SttpClientException, UriContext}
|
||||
import sttp.client3.{asString, HttpError, SttpClientException, UriContext}
|
||||
import sttp.client3.okhttp.OkHttpSyncBackend
|
||||
import sttp.model.MediaType
|
||||
|
||||
@ -22,7 +23,7 @@ object NbnhhshQuery {
|
||||
@throws[HttpError[_]|SttpClientException]
|
||||
def sendGuess (text: String): GuessResult = {
|
||||
case class GuessRequest (text: String)
|
||||
val http = basicRequest
|
||||
val http = mornyBasicRequest
|
||||
.body(Gson().toJson(GuessRequest(text))).contentType(MediaType.ApplicationJson)
|
||||
.post(API_GUESS_METHOD)
|
||||
.response(asString.getRight)
|
@ -1,7 +1,7 @@
|
||||
package cc.sukazyo.cono.morny.data.ip186
|
||||
package cc.sukazyo.cono.morny.extra.ip186
|
||||
|
||||
import cc.sukazyo.cono.morny.util.SttpPublic.Schemes
|
||||
import sttp.client3.{asString, basicRequest, HttpError, SttpClientException, UriContext}
|
||||
import cc.sukazyo.cono.morny.util.SttpPublic.{mornyBasicRequest, Schemes}
|
||||
import sttp.client3.{asString, HttpError, SttpClientException, UriContext}
|
||||
import sttp.client3.okhttp.OkHttpSyncBackend
|
||||
import sttp.model.Uri
|
||||
|
||||
@ -36,7 +36,7 @@ object IP186QueryHandler {
|
||||
val uri = requestPath.scheme(Schemes.HTTPS).host(SITE_HOST)
|
||||
IP186Response(
|
||||
uri.toString,
|
||||
basicRequest
|
||||
mornyBasicRequest
|
||||
.get(uri)
|
||||
.response(asString.getRight)
|
||||
.send(httpClient)
|
@ -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)
|
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 {
|
||||
|
||||
/** The UNIX Epoch Time in milliseconds.
|
||||
*
|
||||
* aka. Milliseconds since 00:00:00 UTC on Thursday, 1 January 1970.
|
||||
*/
|
||||
type EpochMillis = Long
|
||||
type DurationMillis = Long
|
||||
|
||||
object EpochMillis:
|
||||
/** convert a localtime with timezone to epoch milliseconds
|
||||
*
|
||||
@ -26,5 +28,41 @@ object EpochDateTime {
|
||||
def apply (time_zone: (String, String)): EpochMillis =
|
||||
time_zone match
|
||||
case (time, zone) => apply(time, zone)
|
||||
|
||||
/** Convert from [[EpochSeconds]].
|
||||
*
|
||||
* Due to the missing accuracy, the converted EpochMillis will
|
||||
* be always in 0ms aligned.
|
||||
*/
|
||||
def fromEpochSeconds (epochSeconds: EpochSeconds): EpochMillis =
|
||||
epochSeconds.longValue * 1000L
|
||||
|
||||
/** The UNIX Epoch Time in seconds.
|
||||
*
|
||||
* aka. Seconds since 00:00:00 UTC on Thursday, 1 January 1970.
|
||||
*
|
||||
* Normally is the epochSeconds = (epochMillis / 1000)
|
||||
*
|
||||
* Notice that, currently, it stores using [[Int]] (also the implementation
|
||||
* method of Telegram), which will only store times before 2038-01-19 03:14:07.
|
||||
*/
|
||||
type EpochSeconds = Int
|
||||
|
||||
/** The UNIX Epoch Time in day.
|
||||
*
|
||||
* aka. days since 00:00:00 UTC on Thursday, 1 January 1970.
|
||||
*
|
||||
* Normally is the epochDays = (epochMillis / 1000 / 60 / 60 / 24)
|
||||
*
|
||||
* Notice that, currently, it stores using [[Short]] (also the implementation
|
||||
* method of Telegram), which will only store times before 2059-09-18.
|
||||
*/
|
||||
type EpochDays = Short
|
||||
object EpochDays:
|
||||
def fromEpochMillis (epochMillis: EpochMillis): EpochDays =
|
||||
(epochMillis / (1000*60*60*24)).toShort
|
||||
|
||||
/** Time duration/interval in milliseconds. */
|
||||
type DurationMillis = Long
|
||||
|
||||
}
|
||||
|
@ -1,5 +1,9 @@
|
||||
package cc.sukazyo.cono.morny.util
|
||||
|
||||
import cc.sukazyo.cono.morny.MornySystem
|
||||
import sttp.client3.basicRequest
|
||||
import sttp.model.Header
|
||||
|
||||
object SttpPublic {
|
||||
|
||||
object Schemes {
|
||||
@ -7,4 +11,20 @@ object SttpPublic {
|
||||
val HTTPS = "https"
|
||||
}
|
||||
|
||||
object Headers {
|
||||
|
||||
object UserAgent {
|
||||
|
||||
private val key = "User-Agent"
|
||||
|
||||
val MORNY_CURRENT: Header = Header(key, s"MornyCoeur / ${MornySystem.VERSION}")
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
val mornyBasicRequest =
|
||||
basicRequest
|
||||
.header(Headers.UserAgent.MORNY_CURRENT, true)
|
||||
|
||||
}
|
||||
|
@ -16,4 +16,9 @@ object UseMath {
|
||||
def ** (other: Int): Double = Math.pow(self, other)
|
||||
}
|
||||
|
||||
extension (base: Int) {
|
||||
def percentageOf (another: Int): Int =
|
||||
Math.round((another.toDouble/base)*100).toInt
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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 com.pengrad.telegrambot.TelegramBot
|
||||
import com.pengrad.telegrambot.model.{Chat, ChatMember, User}
|
||||
import com.pengrad.telegrambot.model.*
|
||||
import com.pengrad.telegrambot.request.{BaseRequest, GetChatMember}
|
||||
import com.pengrad.telegrambot.response.BaseResponse
|
||||
|
||||
@ -12,13 +12,19 @@ object TelegramExtensions {
|
||||
|
||||
object Bot { extension (bot: TelegramBot) {
|
||||
|
||||
@throws[EventRuntimeException]
|
||||
def exec [T <: BaseRequest[T, R], R <: BaseResponse] (request: T, onError_message: String = ""): R = {
|
||||
val response = bot execute request
|
||||
if response isOk then return response
|
||||
throw EventRuntimeException.ActionFailed(
|
||||
if onError_message isEmpty then response.errorCode toString else onError_message,
|
||||
response
|
||||
)
|
||||
try {
|
||||
val response = bot execute request
|
||||
if response isOk then return response
|
||||
throw EventRuntimeException.ActionFailed(
|
||||
if onError_message isEmpty then response.errorCode toString else onError_message,
|
||||
response
|
||||
)
|
||||
} catch
|
||||
case e: EventRuntimeException.ActionFailed => throw e
|
||||
case e: RuntimeException =>
|
||||
throw EventRuntimeException.ClientFailed(e)
|
||||
}
|
||||
|
||||
}}
|
||||
@ -60,6 +66,14 @@ object TelegramExtensions {
|
||||
|
||||
}}
|
||||
|
||||
object Message { extension (self: Message) {
|
||||
|
||||
def entitiesSafe: List[MessageEntity] =
|
||||
if self.entities == null then Nil else
|
||||
self.entities.toList
|
||||
|
||||
}}
|
||||
|
||||
class LimboUser (id: Long) extends User(id)
|
||||
class LimboChat (val _id: Long) extends Chat() {
|
||||
override val id: java.lang.Long = _id
|
||||
|
@ -2,8 +2,26 @@ package cc.sukazyo.cono.morny.util.tgapi.event
|
||||
|
||||
import com.pengrad.telegrambot.response.BaseResponse
|
||||
|
||||
class EventRuntimeException (message: String) extends RuntimeException(message)
|
||||
/** All possible exception when do Telegram Request.
|
||||
*
|
||||
* Contains following detailed exceptions:
|
||||
* - [[EventRuntimeException.ClientFailed]]
|
||||
* - [[EventRuntimeException.ActionFailed]]
|
||||
*/
|
||||
abstract class EventRuntimeException (message: String) extends RuntimeException(message)
|
||||
|
||||
object EventRuntimeException {
|
||||
/** Telegram API request failed due to the response code is not 200 OK.
|
||||
* @param response Raw API response object.
|
||||
*/
|
||||
class ActionFailed (message: String, val response: BaseResponse) extends EventRuntimeException(message)
|
||||
/** Client exception occurred when sending request.
|
||||
*
|
||||
* It may be some network exception, or parsing API response exception.
|
||||
*
|
||||
* The client exception is stored in [[getCause]].
|
||||
*/
|
||||
class ClientFailed (caused: Exception) extends EventRuntimeException("API client failed.") {
|
||||
this.initCause(caused)
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,11 @@
|
||||
package cc.sukazyo.cono.morny.util.tgapi.formatting
|
||||
|
||||
import org.jsoup.Jsoup
|
||||
import org.jsoup.nodes.Node
|
||||
|
||||
import scala.collection.mutable
|
||||
import scala.jdk.CollectionConverters.*
|
||||
|
||||
object TelegramParseEscape {
|
||||
|
||||
def escapeHtml (input: String): String =
|
||||
@ -9,4 +15,55 @@ object TelegramParseEscape {
|
||||
process = process.replaceAll(">", ">")
|
||||
process
|
||||
|
||||
def cleanupHtml (input: String): String =
|
||||
import org.jsoup.nodes.*
|
||||
val source = Jsoup.parse(input)
|
||||
val x = cleanupHtml(source.body.childNodes.asScala.toSeq)
|
||||
val doc = Document("")
|
||||
doc.outputSettings
|
||||
.prettyPrint(false)
|
||||
x.map(f => doc.appendChild(f))
|
||||
x.mkString("")
|
||||
|
||||
// def toHtmlRaw (input: Node): String =
|
||||
// import org.jsoup.nodes.*
|
||||
// input match
|
||||
// case text: TextNode => text.getWholeText
|
||||
// case _: (DataNode | XmlDeclaration | DocumentType | Comment) => ""
|
||||
// case elem: Element => elem.childNodes.asScala.map(f => toHtmlRaw(f)).mkString("")
|
||||
|
||||
def cleanupHtml (input: Seq[Node]): List[Node] =
|
||||
val result = mutable.ListBuffer.empty[Node]
|
||||
for (i <- input) {
|
||||
import org.jsoup.nodes.*
|
||||
def produceChildNodes (curr: Element): Element =
|
||||
val newOne = Element(curr.tagName)
|
||||
curr.attributes.forEach(attr => newOne.attr(attr.getKey, attr.getValue))
|
||||
for (i <- cleanupHtml(curr.childNodes.asScala.toSeq)) newOne.appendChild(i)
|
||||
newOne
|
||||
i match
|
||||
case text_cdata: CDataNode => result += CDataNode(text_cdata.text)
|
||||
case text: TextNode => result += TextNode(text.getWholeText)
|
||||
case _: (DataNode | XmlDeclaration | DocumentType | Comment) =>
|
||||
case elem: Element => elem match
|
||||
case _: Document => // should not exists here
|
||||
case _: FormElement => // ignored due to Telegram do not support form
|
||||
case elem => elem.tagName match
|
||||
case "a"|"b"|"strong"|"i"|"em"|"u"|"ins"|"s"|"strike"|"del"|"tg-spoiler"|"code"|"pre" =>
|
||||
result += produceChildNodes(elem)
|
||||
case "br" =>
|
||||
result += TextNode("\n")
|
||||
case "tg-emoji" =>
|
||||
if elem.attributes.hasKey("emoji-id") then
|
||||
result += produceChildNodes(elem)
|
||||
else
|
||||
result += TextNode(elem.text)
|
||||
case "img" =>
|
||||
if elem.attributes hasKey "alt" then
|
||||
result += TextNode(s"[${elem attr "alt"}]")
|
||||
case _ =>
|
||||
for (i <- cleanupHtml(elem.childNodes.asScala.toSeq)) result += i
|
||||
}
|
||||
result.toList
|
||||
|
||||
}
|
||||
|
@ -1,7 +1,8 @@
|
||||
package cc.sukazyo.cono.morny.util.tgapi.formatting
|
||||
|
||||
import cc.sukazyo.cono.morny.util.SttpPublic.mornyBasicRequest
|
||||
import com.pengrad.telegrambot.model.User
|
||||
import sttp.client3.{asString, basicRequest, HttpError, SttpClientException, UriContext}
|
||||
import sttp.client3.{asString, HttpError, SttpClientException, UriContext}
|
||||
import sttp.client3.okhttp.OkHttpSyncBackend
|
||||
|
||||
import java.io.IOException
|
||||
@ -17,7 +18,7 @@ object TelegramUserInformation {
|
||||
def getDataCenterFromUser (username: String): String = {
|
||||
|
||||
try
|
||||
val body = basicRequest
|
||||
val body = mornyBasicRequest
|
||||
.get(uri"https://t.me/$username")
|
||||
.response(asString.getRight)
|
||||
.send(httpClient)
|
||||
|
@ -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),
|
||||
("?????", true),
|
||||
("¿", true),
|
||||
("⁈??", true),
|
||||
("?!??", false),
|
||||
("⁇", true),
|
||||
("‽", true),
|
||||
("?⸘?", true),
|
||||
("?", true),
|
||||
("?", true),
|
||||
("??❔", true),
|
||||
("??", true),
|
||||
("❔", true),
|
||||
("❓❓❓", true),
|
||||
// ("⁉️", true)
|
||||
)
|
||||
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 org.scalatest.prop.TableDrivenPropertyChecks
|
||||
import org.scalatest.tagobjects.{Network, Slow}
|
||||
|
||||
class BilibiliFormsTest extends MornyTests with TableDrivenPropertyChecks {
|
||||
|
||||
@ -89,29 +88,34 @@ class BilibiliFormsTest extends MornyTests with TableDrivenPropertyChecks {
|
||||
|
||||
}
|
||||
|
||||
"while destruct b23.tv share link :" - {
|
||||
|
||||
val examples = Table(
|
||||
("b23_link", "bilibili_video_link"),
|
||||
("https://b23.tv/iiCldvZ", "https://www.bilibili.com/video/BV1Gh411P7Sh?buvid=XY6F25B69BE9CF469FF5B917D012C93E95E72&is_story_h5=false&mid=wD6DQnYivIG5pfA3sAGL6A%3D%3D&p=1&plat_id=114&share_from=ugc&share_medium=android&share_plat=android&share_session_id=8081015b-1210-4dea-a665-6746b4850fcd&share_source=COPY&share_tag=s_i×tamp=1689605644&unique_k=iiCldvZ&up_id=19977489"),
|
||||
("http://b23.tv/3ymowwx", "https://www.bilibili.com/video/BV15Y411n754?p=1&share_medium=android_i&share_plat=android&share_source=COPY&share_tag=s_i×tamp=1650293889&unique_k=3ymowwx")
|
||||
)
|
||||
|
||||
"not b23.tv link is not supported" in:
|
||||
an[IllegalArgumentException] should be thrownBy destructB23Url("sukazyo.cc/2xhUHO2e")
|
||||
an[IllegalArgumentException] should be thrownBy destructB23Url("https://sukazyo.cc/2xhUHO2e")
|
||||
an[IllegalArgumentException] should be thrownBy destructB23Url("长月烬明澹台烬心理分析向解析(一)因果之锁,渡魔之路")
|
||||
an[IllegalArgumentException] should be thrownBy destructB23Url("https://b23.tvb/JDo2eaD")
|
||||
an[IllegalArgumentException] should be thrownBy destructB23Url("https://ab23.tv/JDo2eaD")
|
||||
"b23.tv/avXXX video link is not supported" in:
|
||||
an[IllegalArgumentException] should be thrownBy destructB23Url("https://b23.tv/av123456")
|
||||
an[IllegalArgumentException] should be thrownBy destructB23Url("https://b23.tv/BV1Q541167Qg")
|
||||
|
||||
forAll (examples) { (origin, result) =>
|
||||
s"b23 link $origin should be destructed to $result" taggedAs (Slow, Network) in:
|
||||
destructB23Url(origin) shouldEqual result
|
||||
}
|
||||
|
||||
}
|
||||
// Due to this url is expirable, I have no energy to update links in time.
|
||||
// So I decide to deprecate the tests.
|
||||
// "while destruct b23.tv share link :" - {
|
||||
//
|
||||
// val examples = Table(
|
||||
// ("b23_link", "bilibili_video_link"),
|
||||
// ("https://b23.tv/iiCldvZ", "https://www.bilibili.com/video/BV1Gh411P7Sh?buvid=XY6F25B69BE9CF469FF5B917D012C93E95E72&is_story_h5=false&mid=wD6DQnYivIG5pfA3sAGL6A%3D%3D&p=1&plat_id=114&share_from=ugc&share_medium=android&share_plat=android&share_session_id=8081015b-1210-4dea-a665-6746b4850fcd&share_source=COPY&share_tag=s_i×tamp=1689605644&unique_k=iiCldvZ&up_id=19977489"),
|
||||
// ("https://b23.tv/xWiWFl9", "https://www.bilibili.com/video/BV1N54y1c7us?buvid=XY705C970C2ADBB710C1801E1F45BDC3B9210&is_story_h5=false&mid=w%2B1u1wpibjYsW4pP%2FIo7Ww%3D%3D&p=1&plat_id=116&share_from=ugc&share_medium=android&share_plat=android&share_session_id=6da09711-d601-4da4-bba1-46a4edbb1c60&share_source=COPY&share_tag=s_i×tamp=1680280016&unique_k=xWiWFl9&up_id=275354674"),
|
||||
// ("http://b23.tv/uJPIvhv", "https://www.bilibili.com/video/BV1E84y1C7in?is_story_h5=false&p=1&share_from=ugc&share_medium=android&share_plat=android&share_session_id=4a077fa1-5ee2-40d4-ac37-bf9a2bf567e3&share_source=COPY&share_tag=s_i×tamp=1669044671&unique_k=uJPIvhv")
|
||||
// // this link have been expired
|
||||
//// ("http://b23.tv/3ymowwx", "https://www.bilibili.com/video/BV15Y411n754?p=1&share_medium=android_i&share_plat=android&share_source=COPY&share_tag=s_i×tamp=1650293889&unique_k=3ymowwx")
|
||||
// )
|
||||
//
|
||||
// "not b23.tv link is not supported" in:
|
||||
// an[IllegalArgumentException] should be thrownBy destructB23Url("sukazyo.cc/2xhUHO2e")
|
||||
// an[IllegalArgumentException] should be thrownBy destructB23Url("https://sukazyo.cc/2xhUHO2e")
|
||||
// an[IllegalArgumentException] should be thrownBy destructB23Url("长月烬明澹台烬心理分析向解析(一)因果之锁,渡魔之路")
|
||||
// an[IllegalArgumentException] should be thrownBy destructB23Url("https://b23.tvb/JDo2eaD")
|
||||
// an[IllegalArgumentException] should be thrownBy destructB23Url("https://ab23.tv/JDo2eaD")
|
||||
// "b23.tv/avXXX video link is not supported" in:
|
||||
// an[IllegalArgumentException] should be thrownBy destructB23Url("https://b23.tv/av123456")
|
||||
// an[IllegalArgumentException] should be thrownBy destructB23Url("https://b23.tv/BV1Q541167Qg")
|
||||
//
|
||||
// forAll (examples) { (origin, result) =>
|
||||
// s"b23 link $origin should be destructed to $result" taggedAs (Slow, Network) in:
|
||||
// destructB23Url(origin) shouldEqual result
|
||||
// }
|
||||
//
|
||||
// }
|
||||
|
||||
}
|
@ -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
|
||||
|
||||
import cc.sukazyo.cono.morny.test.MornyTests
|
||||
import cc.sukazyo.cono.morny.util.EpochDateTime.EpochMillis
|
||||
import cc.sukazyo.cono.morny.util.EpochDateTime.{EpochDays, EpochMillis, EpochSeconds}
|
||||
import org.scalatest.prop.TableDrivenPropertyChecks
|
||||
|
||||
class EpochDateTimeTest extends MornyTests with TableDrivenPropertyChecks {
|
||||
|
||||
"while converting to EpochMillis :" - {
|
||||
|
||||
"from EpochSeconds :" - {
|
||||
|
||||
val examples = Table[EpochSeconds, EpochMillis](
|
||||
("EpochSeconds", "EpochMillis"),
|
||||
(1699176068, 1699176068000L),
|
||||
(1699176000, 1699176000000L),
|
||||
(1, 1000L),
|
||||
)
|
||||
|
||||
forAll(examples) { (epochSeconds, epochMillis) =>
|
||||
s"EpochSeconds($epochSeconds) should be converted to EpochMillis($epochMillis)" in {
|
||||
(EpochMillis fromEpochSeconds epochSeconds) shouldEqual epochMillis
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
"while converting to EpochDays :" - {
|
||||
|
||||
"from EpochMillis :" - {
|
||||
|
||||
val examples = Table(
|
||||
("EpochMillis", "EpochDays"),
|
||||
(0L, 0),
|
||||
(1000L, 0),
|
||||
(80000000L, 0),
|
||||
(90000000L, 1),
|
||||
(1699176549059L, 19666)
|
||||
)
|
||||
|
||||
forAll(examples) { (epochMillis, epochDays) =>
|
||||
s"EpochMillis($epochMillis) should be converted to EpochDays($epochDays)" in {
|
||||
(EpochDays fromEpochMillis epochMillis) shouldEqual epochDays
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
"while converting date-time string to time-millis : " - {
|
||||
|
||||
"while using ISO-Offset-Date-Time : " - {
|
||||
|
@ -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