add simple/complex error message framework.

This commit is contained in:
A.C.Sukazyo Eyre 2024-02-25 11:30:34 +08:00
parent 025f152417
commit 4908110c80
Signed by: Eyre_S
GPG Key ID: C17CE40291207874
8 changed files with 292 additions and 21 deletions

View File

@ -8,7 +8,7 @@ object MornyConfiguration {
val MORNY_CODE_STORE = "https://github.com/Eyre-S/Coeur-Morny-Cono"
val MORNY_COMMIT_PATH = "https://github.com/Eyre-S/Coeur-Morny-Cono/commit/%s"
val VERSION = "2.0.0-alpha16"
val VERSION = "2.0.0-alpha17"
val VERSION_DELTA: Option[String] = None
val CODENAME = "guanggu"

View File

@ -5,7 +5,7 @@ import cc.sukazyo.cono.morny.core.MornyCoeur.*
import cc.sukazyo.cono.morny.core.bot.api.{EventListenerManager, MornyCommandManager, MornyQueryManager}
import cc.sukazyo.cono.morny.core.bot.api.messages.ThreadingManager
import cc.sukazyo.cono.morny.core.bot.event.{MornyOnInlineQuery, MornyOnTelegramCommand, MornyOnUpdateTimestampOffsetLock}
import cc.sukazyo.cono.morny.core.bot.internal.ThreadingManagerImpl
import cc.sukazyo.cono.morny.core.bot.internal.{ErrorMessageManager, ThreadingManagerImpl}
import cc.sukazyo.cono.morny.core.http.api.{HttpServer, MornyHttpServerContext}
import cc.sukazyo.cono.morny.core.http.internal.MornyHttpServerContextImpl
import cc.sukazyo.cono.morny.reporter.MornyReport
@ -171,6 +171,7 @@ class MornyCoeur (modules: List[MornyModule])(using val config: MornyConfig)(tes
val trusted: MornyTrusted = MornyTrusted()
private val _messageThreading: ThreadingManagerImpl = ThreadingManagerImpl(using account)
val messageThreading: ThreadingManager = _messageThreading
val errorMessageManager: ErrorMessageManager = ErrorMessageManager()
val eventManager: EventListenerManager = EventListenerManager()
val commands: MornyCommandManager = MornyCommandManager()
@ -211,6 +212,7 @@ class MornyCoeur (modules: List[MornyModule])(using val config: MornyConfig)(tes
DirectMsgClear(),
_messageThreading.CancelCommand,
errorMessageManager.ShowErrorMessageCommand,
)
}

View File

@ -0,0 +1,23 @@
package cc.sukazyo.cono.morny.core.bot.api
import cc.sukazyo.cono.morny.core.MornyCoeur
import cc.sukazyo.cono.morny.core.bot.api.messages.ErrorMessage
/** Bot extensions for Morny feature.
*/
object BotExtension {
extension (errorMessage: ErrorMessage[?, ?]) {
/** Submit this [[ErrorMessage]] to a [[MornyCoeur]].
*
* Will send this [[ErrorMessage]] with the basic send config.
*
* @see [[cc.sukazyo.cono.morny.core.bot.internal.ErrorMessageManager.sendErrorMessage]]
*/
def submit (using coeur: MornyCoeur): Unit =
coeur.errorMessageManager.sendErrorMessage(errorMessage)
}
}

View File

@ -0,0 +1,71 @@
package cc.sukazyo.cono.morny.core.bot.api.messages
import com.pengrad.telegrambot.request.AbstractSendRequest
/** A error message based on Telegram's [[AbstractSendRequest]].
*
* Contains two types ([[simple]] and [[complex]]) message that
* allows to choose one to show in different cases.
*
* Also there contains a [[context]] (typed with [[MessagingContext.WithMessage]])
* that infers the source of this error message, also can be used for
* the unique key of one error message.
*
* There's also a [[ErrorMessage.Types]] enum infers to the two type. You
* can use [[getByType]] (or [[getByTypeNormal]]) method to get the specific
* type's message of that type.
*
* @since 2.0.0
*
* @see [[cc.sukazyo.cono.morny.core.bot.internal.ErrorMessageManager]] This
* ErrorMessage's manager (consumer) implementation by the Morny Coeur.
* @see [[cc.sukazyo.cono.morny.core.bot.api.BotExtension.submit]] Simple
* way to consume this.
*
* @tparam T1 Type of the simple error message
* @tparam T2 Type of the complex error message
*/
trait ErrorMessage[T1 <: AbstractSendRequest[T1], T2 <: AbstractSendRequest[T2]] {
val simple: T1
val complex: T2
val context: MessagingContext.WithMessage
/** Get the simple or complex message by the given [[ErrorMessage.Types]] infer.
*
* This method returns a union type [[T1]] and [[T2]]. This may be
* not useful when you don't care about the specific return types (maybe for
* the most times). You can use [[getByTypeNormal]] instead.
*/
def getByType (t: ErrorMessage.Types): AbstractSendRequest[T1] | AbstractSendRequest[T2] =
t match
case ErrorMessage.Types.Simple => simple
case ErrorMessage.Types.Complex => complex
/** Get the simple or complex message by the given [[ErrorMessage.Types]] infer.
*
* This works exactly the same with the [[getByType]], the only difference is
* this returns with a bit universal type `AbstractSendRequest[?]`.
*
* @see [[getByType]]
*/
def getByTypeNormal (t: ErrorMessage.Types): AbstractSendRequest[?] =
getByType(t)
}
object ErrorMessage {
enum Types:
case Simple
case Complex
def apply [T1 <: AbstractSendRequest[T1], T2<: AbstractSendRequest[T2]]
(_simple: T1, _complex: T2)(using cxt: MessagingContext.WithMessage): ErrorMessage[T1, T2] =
new ErrorMessage[T1, T2]:
override val simple: T1 = _simple
override val complex: T2 = _complex
override val context: MessagingContext.WithMessage = cxt
}

View File

@ -1,5 +1,6 @@
package cc.sukazyo.cono.morny.core.bot.api.messages
import cc.sukazyo.cono.morny.util.tgapi.Standardize.*
import com.pengrad.telegrambot.model.{Chat, Message, User}
/**
@ -7,35 +8,65 @@ import com.pengrad.telegrambot.model.{Chat, Message, User}
*/
trait MessagingContext:
val bind_chat: Chat
def toChatKey: MessagingContext.Key =
MessagingContext.Key(this)
/**
* @since 2.0.0
*/
object MessagingContext {
given String = "aaa"
case class Key (chatId: ChatID)
object Key:
def apply (it: MessagingContext): Key = Key(it.bind_chat.id)
def apply (_chat: Chat): MessagingContext =
new MessagingContext:
override val bind_chat: Chat = _chat
trait WithUser extends MessagingContext:
val bind_user: User
def toChatUserKey: WithUser.Key =
WithUser.Key(this)
object WithUser:
case class Key (chatID: ChatID, userId: UserID)
object Key:
def apply (it: WithUser): Key = Key(it.bind_chat.id, it.bind_user.id)
def apply (_chat: Chat, _user: User): WithUser =
new WithUser:
override val bind_chat: Chat = _chat
override val bind_user: User = _user
def apply (_chat: Chat, _user: User): WithUser =
new WithUser:
override val bind_chat: Chat = _chat
override val bind_user: User = _user
WithUser(_chat, _user)
trait WithMessage extends MessagingContext:
val bind_message: Message
def toChatMessageKey: WithMessage.Key =
WithMessage.Key(this)
object WithMessage:
case class Key (chatId: ChatID, messageId: MessageID)
object Key:
def apply (it: WithMessage): Key = Key(it.bind_chat.id, it.bind_message.messageId)
def apply (_chat: Chat, _message: Message): WithMessage =
new WithMessage:
override val bind_chat: Chat = _chat
override val bind_message: Message = _message
def apply (_chat: Chat, _message: Message): WithMessage =
new WithMessage:
override val bind_chat: Chat = _chat
override val bind_message: Message = _message
trait WithUserAndMessage extends MessagingContext with WithMessage with WithUser
WithMessage(_chat, _message)
trait WithUserAndMessage extends MessagingContext with WithMessage with WithUser:
def toChatUserMessageKey: WithUserAndMessage.Key =
WithUserAndMessage.Key(this)
object WithUserAndMessage:
case class Key (chatId: ChatID, userId: UserID, messageId: MessageID)
object Key:
def apply (it: WithUserAndMessage): Key = Key(it.bind_chat.id, it.bind_user.id, it.bind_message.messageId)
def apply (_chat: Chat, _user: User, _message: Message): WithUserAndMessage =
new WithUserAndMessage:
override val bind_chat: Chat = _chat
override val bind_user: User = _user
override val bind_message: Message = _message
def apply (_chat: Chat, _user: User, _message: Message): WithUserAndMessage =
new WithUserAndMessage:
override val bind_chat: Chat = _chat
override val bind_user: User = _user
override val bind_message: Message = _message
WithUserAndMessage(_chat, _user, _message)
/** Extract a message context from a message (or message event).
*

View File

@ -2,6 +2,7 @@ package cc.sukazyo.cono.morny.core.bot.command
import cc.sukazyo.cono.morny.core.{MornyCoeur, MornySystem}
import cc.sukazyo.cono.morny.core.bot.api.{ICommandAlias, ITelegramCommand}
import cc.sukazyo.cono.morny.core.bot.api.messages.{ErrorMessage, MessagingContext}
import cc.sukazyo.cono.morny.data.MornyInformation.*
import cc.sukazyo.cono.morny.data.TelegramStickers
import cc.sukazyo.cono.morny.reporter.MornyReport
@ -36,7 +37,7 @@ class MornyInformation (using coeur: MornyCoeur) extends ITelegramCommand {
override def execute (using command: InputCommand, event: Update): Unit = {
if (command.args isEmpty) {
echoInfo(event.message.chat.id, event.message.messageId)
echoInfo(event)
return
}
@ -53,9 +54,24 @@ class MornyInformation (using coeur: MornyCoeur) extends ITelegramCommand {
}
private def echoInfo (chatId: Long, replyTo: Int): Unit = {
private def echoInfo (update: Update): Unit = {
val cxt = MessagingContext.extract(using update.message)
val cxtReplied = Option(update.message.replyToMessage).map(MessagingContext.extract(using _))
cxtReplied match
case None =>
case Some(_cxtReplied) =>
// check if theres associated information about error message on the replied context.
// if there really is, that means the replied message is a error message,
// so the error message's complex information will be sent
coeur.errorMessageManager.inspectMessage(_cxtReplied.toChatMessageKey) match
case None =>
case Some(errMessage) =>
coeur.errorMessageManager.sendErrorMessage(errMessage, ErrorMessage.Types.Complex, Some(cxt))
// if theres no any associated information on the context
SendPhoto(
chatId,
cxt.bind_chat.id,
getAboutPic
).caption(
s"""<b>Morny Cono</b>
@ -63,8 +79,9 @@ class MornyInformation (using coeur: MornyCoeur) extends ITelegramCommand {
|
|$getMornyAboutLinksHTML"""
.stripMargin
).parseMode(ParseMode HTML).replyToMessageId(replyTo)
).parseMode(ParseMode HTML).replyToMessageId(cxt.bind_message.messageId)
.unsafeExecute
}
private def echoStickers (using command: InputCommand, event: Update): Unit = {

View File

@ -0,0 +1,104 @@
package cc.sukazyo.cono.morny.core.bot.internal
import cc.sukazyo.cono.morny.core.MornyCoeur
import cc.sukazyo.cono.morny.core.bot.api.{ICommandAlias, ISimpleCommand}
import cc.sukazyo.cono.morny.core.bot.api.messages.{ErrorMessage, MessagingContext}
import cc.sukazyo.cono.morny.util.schedule.{DelayedTask, Scheduler}
import cc.sukazyo.cono.morny.util.tgapi.InputCommand
import cc.sukazyo.cono.morny.util.tgapi.TelegramExtensions.Requests.unsafeExecute
import cc.sukazyo.cono.morny.util.EpochDateTime.DurationMillis
import com.pengrad.telegrambot.TelegramBot
import com.pengrad.telegrambot.model.Update
import com.pengrad.telegrambot.request.{AbstractSendRequest, SendMessage}
class ErrorMessageManager (using coeur: MornyCoeur) {
given TelegramBot = coeur.account
private val expiresDuration: DurationMillis = 1000 * 60 * 60 * 5 // 5 hours
private val errorMessageMap = collection.mutable.Map[MessagingContext.WithMessage.Key, ErrorMessage[?, ?]]()
private val errorMessageMapCleaner = Scheduler(isDaemon = true)
private def putErrorMessage (key: MessagingContext.WithMessage.Key, message: ErrorMessage[?, ?]): Unit =
errorMessageMap.synchronized: // remove an error message after the given expires duration.
errorMessageMap += (key -> message)
errorMessageMapCleaner ++ DelayedTask("", expiresDuration, {
errorMessageMap.remove(key)
})
/** Send an [[ErrorMessage]] and add this error message to ErrorMessage stash.
*
* This will execute the SendRequest using the default send config.
*/
def sendErrorMessage (message: ErrorMessage[?, ?]): Unit =
// todo: which should be sent using user config
val useType = ErrorMessage.Types.Simple
sendErrorMessage(message, useType, isNewMessage = true)
/** Send an [[ErrorMessage]] and add it to the stash if it is new.
*
* @param message The [[ErrorMessage]] to be sent.
* @param useType Which type of the message should be sent.
* @param injectedSendContext A context that determine where and how the message should
* be sent. Currently only the message id in this context will
* be used to determine the reply to message.
*
* This is part of the API limitation, but due to in normal cases,
* the source error message must be in the same chat so it may
* make sense.
* @param isNewMessage Is this ErrorMessage is a new error message, otherwise it should be
* an existed error message that just calling this method because of
* need to be resent some messages.
*
* If this is new, the message will be added to the stash, if not, it
* should be comes from the stash and will not be added to the stash again.
*
* Default is false.
*/
def sendErrorMessage (
message: ErrorMessage[?, ?],
useType: ErrorMessage.Types,
injectedSendContext: Option[MessagingContext.WithMessage] = None,
isNewMessage: Boolean = false
): Unit = {
val sendMessage = message
.getByTypeNormal(useType)
.asInstanceOf[AbstractSendRequest[Nothing]]
injectedSendContext match
case None =>
case Some(cxt) =>
sendMessage.replyToMessageId(cxt.bind_message.messageId)
val response = sendMessage.unsafeExecute
if isNewMessage then
val key = MessagingContext.extract(using response.message).toChatMessageKey
putErrorMessage(key, message)
}
/** Get the stashed [[ErrorMessage]] associated with the [[MessagingContext.WithMessage.Key message key]].
*
* If the message key does not associated with any error message in the stash, then
* [[None]] will be returned.
*
* Notice that one [[ErrorMessage]] will only be stashed for 5 hours then it will be
* cleaned up from the stash.
*/
def inspectMessage (messageKey: MessagingContext.WithMessage.Key): Option[ErrorMessage[?, ?]] =
errorMessageMap.get(messageKey)
object ShowErrorMessageCommand extends ISimpleCommand {
override val name: String = "inspect"
override val aliases: List[ICommandAlias] = Nil
override def execute (using command: InputCommand, event: Update): Unit =
val cxt = MessagingContext.extract(using event.message)
val cxtReplied = Option(event.message.replyToMessage).map(MessagingContext.extract(using _))
errorMessageMap.get(cxtReplied.map(_.toChatMessageKey).orNull) match
case Some(msg) =>
sendErrorMessage(msg, ErrorMessage.Types.Complex, Some(cxt))
case None =>
SendMessage(
cxt.bind_chat.id,
"Not a error message."
).replyToMessageId(cxt.bind_message.messageId)
.unsafeExecute
}
}

View File

@ -2,12 +2,14 @@ package cc.sukazyo.cono.morny.morny_misc
import cc.sukazyo.cono.morny.core.MornyCoeur
import cc.sukazyo.cono.morny.core.bot.api.{ICommandAlias, ISimpleCommand}
import cc.sukazyo.cono.morny.core.bot.api.messages.MessagingContext
import cc.sukazyo.cono.morny.core.bot.api.messages.{ErrorMessage, MessagingContext}
import cc.sukazyo.cono.morny.core.bot.api.BotExtension.submit
import cc.sukazyo.cono.morny.data.TelegramStickers
import cc.sukazyo.cono.morny.util.tgapi.InputCommand
import cc.sukazyo.cono.morny.util.tgapi.TelegramExtensions.Requests.unsafeExecute
import com.pengrad.telegrambot.model.{Message, Update}
import com.pengrad.telegrambot.model.request.ParseMode
import com.pengrad.telegrambot.request.SendMessage
import com.pengrad.telegrambot.request.{SendMessage, SendSticker}
import com.pengrad.telegrambot.TelegramBot
class Testing (using coeur: MornyCoeur) extends ISimpleCommand {
@ -32,12 +34,33 @@ class Testing (using coeur: MornyCoeur) extends ISimpleCommand {
}
private def execute2 (message: Message, previousContext: MessagingContext.WithUserAndMessage): Unit = {
if (message.text == "oops")
SendMessage(
message.chat.id,
"A test error message will be generated."
).replyToMessageId(message.messageId)
.unsafeExecute
ErrorMessage(
_simple = SendSticker(
message.chat.id,
TelegramStickers.ID_404
).replyToMessageId(message.messageId),
_complex = SendMessage(
message.chat.id,
"Oops: There is just a test error."
).replyToMessageId(message.messageId)
)(using MessagingContext.extract(using message))
.submit
return;
SendMessage(
message.chat.id,
// language=html
"<b><u>Test command with following input:</u></b>\n" + message.text
).replyToMessageId(message.messageId).parseMode(ParseMode HTML)
.unsafeExecute
}
}