From 4908110c8050a2d6b620faa686a02fb4eb7c86c7 Mon Sep 17 00:00:00 2001 From: Eyre_S Date: Sun, 25 Feb 2024 11:30:34 +0800 Subject: [PATCH] add simple/complex error message framework. --- project/MornyConfiguration.scala | 2 +- .../sukazyo/cono/morny/core/MornyCoeur.scala | 4 +- .../morny/core/bot/api/BotExtension.scala | 23 ++++ .../core/bot/api/messages/ErrorMessage.scala | 71 ++++++++++++ .../bot/api/messages/MessagingContext.scala | 57 +++++++--- .../core/bot/command/MornyInformation.scala | 25 ++++- .../bot/internal/ErrorMessageManager.scala | 104 ++++++++++++++++++ .../cono/morny/morny_misc/Testing.scala | 27 ++++- 8 files changed, 292 insertions(+), 21 deletions(-) create mode 100644 src/main/scala/cc/sukazyo/cono/morny/core/bot/api/BotExtension.scala create mode 100644 src/main/scala/cc/sukazyo/cono/morny/core/bot/api/messages/ErrorMessage.scala create mode 100644 src/main/scala/cc/sukazyo/cono/morny/core/bot/internal/ErrorMessageManager.scala diff --git a/project/MornyConfiguration.scala b/project/MornyConfiguration.scala index 9d8a1d2..6ec9177 100644 --- a/project/MornyConfiguration.scala +++ b/project/MornyConfiguration.scala @@ -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" diff --git a/src/main/scala/cc/sukazyo/cono/morny/core/MornyCoeur.scala b/src/main/scala/cc/sukazyo/cono/morny/core/MornyCoeur.scala index cb6b6c5..53748a5 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/core/MornyCoeur.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/core/MornyCoeur.scala @@ -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, ) } diff --git a/src/main/scala/cc/sukazyo/cono/morny/core/bot/api/BotExtension.scala b/src/main/scala/cc/sukazyo/cono/morny/core/bot/api/BotExtension.scala new file mode 100644 index 0000000..fdb5cc2 --- /dev/null +++ b/src/main/scala/cc/sukazyo/cono/morny/core/bot/api/BotExtension.scala @@ -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) + + } + +} diff --git a/src/main/scala/cc/sukazyo/cono/morny/core/bot/api/messages/ErrorMessage.scala b/src/main/scala/cc/sukazyo/cono/morny/core/bot/api/messages/ErrorMessage.scala new file mode 100644 index 0000000..ee4ddb7 --- /dev/null +++ b/src/main/scala/cc/sukazyo/cono/morny/core/bot/api/messages/ErrorMessage.scala @@ -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 + +} diff --git a/src/main/scala/cc/sukazyo/cono/morny/core/bot/api/messages/MessagingContext.scala b/src/main/scala/cc/sukazyo/cono/morny/core/bot/api/messages/MessagingContext.scala index 601ed56..7a70892 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/core/bot/api/messages/MessagingContext.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/core/bot/api/messages/MessagingContext.scala @@ -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). * diff --git a/src/main/scala/cc/sukazyo/cono/morny/core/bot/command/MornyInformation.scala b/src/main/scala/cc/sukazyo/cono/morny/core/bot/command/MornyInformation.scala index fc0cc3a..977af0d 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/core/bot/command/MornyInformation.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/core/bot/command/MornyInformation.scala @@ -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"""Morny Cono @@ -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 = { diff --git a/src/main/scala/cc/sukazyo/cono/morny/core/bot/internal/ErrorMessageManager.scala b/src/main/scala/cc/sukazyo/cono/morny/core/bot/internal/ErrorMessageManager.scala new file mode 100644 index 0000000..0472bcf --- /dev/null +++ b/src/main/scala/cc/sukazyo/cono/morny/core/bot/internal/ErrorMessageManager.scala @@ -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 + } + +} diff --git a/src/main/scala/cc/sukazyo/cono/morny/morny_misc/Testing.scala b/src/main/scala/cc/sukazyo/cono/morny/morny_misc/Testing.scala index 5a25fc7..92919ce 100644 --- a/src/main/scala/cc/sukazyo/cono/morny/morny_misc/Testing.scala +++ b/src/main/scala/cc/sukazyo/cono/morny/morny_misc/Testing.scala @@ -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 "Test command with following input:\n" + message.text ).replyToMessageId(message.messageId).parseMode(ParseMode HTML) .unsafeExecute + } }