Compare commits

...

20 Commits

Author SHA1 Message Date
961edd93f3
[[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)
2023-12-06 21:51:29 +08:00
c4632263de
make private url share can get from content 2023-12-02 21:11:33 +08:00
ad65ab7a73
SocialContent add non-pic alternative 2023-11-30 00:13:29 +08:00
20c9916535
add show retweet weibo 2023-11-29 21:08:08 +08:00
79d41d5e72
basic inline get social function
- Now supported get social content from inline
  - use a supported url with prefix or suffix "get"
  - only support twitter photos media
  - support all types of pure text content.
  - trying get non-supported medias may cause failure.
2023-11-29 17:16:02 +08:00
3d1699ea1d
move external API docking code to extra package from data 2023-11-29 00:15:13 +08:00
a9767ec1b0
change /tweet to /get and added support for weibo content 2023-11-27 18:58:35 +08:00
d602e1b366
set morny UA for all HTTP req, add twitter tests 2023-11-23 17:57:29 +08:00
43cdf221d9
fix request client failed caused unexpected exception 2023-11-22 16:06:15 +08:00
f8b2d056cc
FixTweet api implement, with a /tweet command 2023-11-21 23:35:12 +08:00
2687c3be88
add FixTweet, removed c.vxtwitter 2023-11-20 17:12:10 +08:00
c5c6683459
for event, fix wrong OK stats, add CANCELED tag
- Now the status of EventEnv is a State array that infers the state history
  - State can be OK or CANCELED, and can be set multiple times
  - state method can get the last state set, and status method can get the state history
  - Default EventListener.executeFilter implementation is changed to true if stats is null
- add consume[T](T=>Unit) for EventEnv, to simplifying the old consume[T](Class[T])(T=>Unit)
- changed execution sort of EventListener in EventListenerManager. Now atEventPost method will be run after all events' normal listeners complete.
- cha OnMedicationNotifyApply will only tag event as OK when the refresh function works (fixed part of the wrong OK state)
- cha MornyOnUpdateTimestampOffsetLock tag event CANCELED but not OK to fix part of the wrong OK state
2023-11-20 11:18:32 +08:00
7ee4a0d3c5
add code language support for event_hack and exception report 2023-11-17 01:19:22 +08:00
720771719e
add err handler for UpdateListener
- add err handler for UpdateListener in MornyCoeur
  - for network-related exception, will only output exception basic message and not report.
  - for other type of exception, will output all message of exception and do report.
- Scheduler's runnerName now is `$classBaseName@$hashCode`
2023-11-16 20:06:15 +08:00
2c30b5ec09
add event statistics, fix CronTask
- add for EventEnv a timeStartup field
- cha EventListener and EventListenerManager
  - add for EventListener a method executeFilter used to manager if an event should be run. This replaced the condition statement inside the EventListenerManager
  - add for EventListener a method atEventPost, this will run at current event listener is on complete
- add for MornyConfig a reportZone field
  - can be set by `--report-zone`
  - used for controlling Morny Report daemon uses the zoned time to send report. default is system default time zone.
- add for MornyReport new EventStatistics and DailyReportTask
- add for MornyInformation command new subcommand `event` to manually show MornyReport.EventStatistics info.
- add WatchDog and MornyCoeur.watchDog, used for checking if the machine is in sleep mode and notify the MornyCoeur.tasks to avoid timing problem
- fix CronTask frequency got initialize problem
- add slf4j-nop for project
2023-11-14 13:56:23 +08:00
3d44972233
add CronTask, tests optimize
- add lib cron-utils: v9.2.0
- add CronTask
  - add CronTask's test
- change MedicationTimer using cron as time calculation backend (not using CronTask)
- change OnQuestionMarkReply support `⸘`
- minor SchedulerTest "immediately" test logic changes
2023-11-09 22:07:10 +08:00
89c414e853
Merge branch 'master' into 1.3.0 2023-11-08 18:33:42 +08:00
9f908aa88e
add scaladoc, change Long to EpochMillis, scheduler tests
- fix wrong Telegram EpochSeconds to EpochMillis conv at OnCallMe
2023-11-05 19:25:00 +08:00
f0d4471646
add some scaladoc for scheduler 2023-11-04 19:38:40 +08:00
b57d87dece
add basic Scheduler and interval tasks
- MedicationTimer refactor using new scheduler
- add `/info tasks` for monitoring morny global tasks
2023-11-03 20:23:32 +08:00
78 changed files with 2906 additions and 211 deletions

View File

@ -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()

View File

@ -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

View File

@ -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) {
}
}
}

View File

@ -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<>();

View File

@ -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

View File

@ -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

View File

@ -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 = {}

View File

@ -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")
}
}
}

View File

@ -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)

View File

@ -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()
}
}

View File

@ -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

View File

@ -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(),

View File

@ -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,

View File

@ -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

View File

@ -17,6 +17,7 @@ class MornyEventListeners (using manager: EventListenerManager) (using coeur: Mo
OnUserSlashAction(),
OnCallMe(),
OnCallMsgSend(),
OnGetSocial(),
OnMedicationNotifyApply(),
OnEventHackHandle()
)

View File

@ -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)

View File

@ -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

View 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")
}

View File

@ -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
}
}

View File

@ -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] {

View File

@ -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) {

View File

@ -12,7 +12,8 @@ class MornyQueries (using MornyCoeur) {
RawText(),
MyInformation(),
ShareToolTwitter(),
ShareToolBilibili()
ShareToolBilibili(),
ShareToolSocialContent()
)
def query (event: Update): List[InlineQueryUnit[_]] = {

View File

@ -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)

View File

@ -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
}
}

View File

@ -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"
))
)

View File

@ -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
}

View File

@ -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
}
}

View File

@ -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."
}

View File

@ -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>&lt;no-statistics&gt;</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
}
}

View File

@ -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)

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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)

View 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
}
}

View File

@ -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
)
}

View File

@ -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
)

View File

@ -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
)
}

View File

@ -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
)

View File

@ -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
)

View File

@ -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
)

View File

@ -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
)

View File

@ -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]
)
}

View File

@ -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
)

View File

@ -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
}

View 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)
}
}

View 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
)
}
}

View File

@ -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,
)

View 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],
)

View File

@ -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}"
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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
}
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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()
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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

View File

@ -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)
}
}

View File

@ -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(">", "&gt;")
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
}

View File

@ -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)

View File

@ -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)
}

View File

@ -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) =>

View File

@ -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&timestamp=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&timestamp=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&timestamp=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&timestamp=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&timestamp=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&timestamp=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
// }
//
// }
}

View File

@ -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))
}
}
}
}

View File

@ -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)
}
}
}

View File

@ -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 : " - {

View File

@ -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
}
}

View File

@ -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")
}
}

View File

@ -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")
}
}
}

View File

@ -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"
}
}