diff --git a/bot/api/service/src/main/kotlin/BotApiHandler.kt b/bot/api/service/src/main/kotlin/BotApiHandler.kt index 9bcf6261b8..ad58c2e801 100644 --- a/bot/api/service/src/main/kotlin/BotApiHandler.kt +++ b/bot/api/service/src/main/kotlin/BotApiHandler.kt @@ -33,6 +33,7 @@ import ai.tock.bot.connector.media.MediaCard import ai.tock.bot.connector.media.MediaCarousel import ai.tock.bot.connector.media.MediaFile import ai.tock.bot.definition.StoryDefinition +import ai.tock.bot.definition.StoryStep import ai.tock.bot.engine.BotBus import ai.tock.bot.engine.action.Action import ai.tock.bot.engine.action.SendAttachment.AttachmentType @@ -134,7 +135,7 @@ internal class BotApiHandler( } // set step if (response.step != null) { - step = story.definition.allSteps().find { it.name == response.step } + step = story.definition.allSteps().find { it.name == response.step } as? StoryStep<*> } //Handle current story and switch to ending story diff --git a/bot/api/service/src/test/kotlin/BotApiHandlerTest.kt b/bot/api/service/src/test/kotlin/BotApiHandlerTest.kt index c464048663..0d042d3e7f 100644 --- a/bot/api/service/src/test/kotlin/BotApiHandlerTest.kt +++ b/bot/api/service/src/test/kotlin/BotApiHandlerTest.kt @@ -25,8 +25,8 @@ import ai.tock.bot.api.model.websocket.ResponseData import ai.tock.bot.api.service.BotApiClientController import ai.tock.bot.api.service.BotApiDefinitionProvider import ai.tock.bot.api.service.BotApiHandler -import ai.tock.bot.definition.BotDefinition import ai.tock.bot.api.service.toUserRequest +import ai.tock.bot.definition.BotDefinition import ai.tock.bot.definition.StoryDefinition import ai.tock.bot.engine.BotBus import ai.tock.bot.engine.action.ActionMetadata @@ -34,8 +34,8 @@ import ai.tock.bot.engine.action.SendSentence import ai.tock.bot.engine.dialog.Dialog import ai.tock.bot.engine.dialog.DialogState import ai.tock.bot.engine.dialog.NextUserActionState -import ai.tock.bot.engine.user.UserTimelineDAO import ai.tock.bot.engine.user.PlayerId +import ai.tock.bot.engine.user.UserTimelineDAO import ai.tock.nlp.api.client.model.NlpIntentQualifier import ai.tock.shared.tockInternalInjector import com.github.salomonbrys.kodein.Kodein @@ -47,11 +47,11 @@ import io.mockk.justRun import io.mockk.mockk import io.mockk.slot import io.mockk.verify +import kotlin.test.assertEquals +import kotlin.test.assertTrue import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue class BotApiHandlerTest { @@ -140,7 +140,7 @@ class BotApiHandlerTest { handler.send(bus) verify { bus.setBusContextValue("_viewed_stories_tock_switch", any()) } - verify { bus.handleAndSwitchStory(any(), any()) } + verify { bus.handleAndSwitchStory(any(), any()) } } @Test diff --git a/bot/connector-alcmeon/src/main/kotlin/AlcmeonHandler.kt b/bot/connector-alcmeon/src/main/kotlin/AlcmeonHandler.kt index e7b3bbfcd1..0bd54177e6 100644 --- a/bot/connector-alcmeon/src/main/kotlin/AlcmeonHandler.kt +++ b/bot/connector-alcmeon/src/main/kotlin/AlcmeonHandler.kt @@ -16,6 +16,7 @@ package ai.tock.bot.connector.alcmeon import ai.tock.bot.connector.ConnectorHandler +import ai.tock.bot.definition.ConnectorSpecificHandling import ai.tock.bot.definition.ConnectorStoryHandler import ai.tock.bot.definition.StoryHandlerDefinition import kotlin.reflect.KClass @@ -28,4 +29,4 @@ import kotlin.reflect.KClass @ConnectorHandler(connectorTypeId = ALCMEON_CONNECTOR_TYPE_ID) @Target(AnnotationTarget.CLASS) @MustBeDocumented -annotation class AlcmeonHandler(val value: KClass>) +annotation class AlcmeonHandler(val value: KClass) diff --git a/bot/connector-alexa/src/main/kotlin/AlexaHandler.kt b/bot/connector-alexa/src/main/kotlin/AlexaHandler.kt index 58d97c692e..180b91b61f 100644 --- a/bot/connector-alexa/src/main/kotlin/AlexaHandler.kt +++ b/bot/connector-alexa/src/main/kotlin/AlexaHandler.kt @@ -17,15 +17,18 @@ package ai.tock.bot.connector.alexa import ai.tock.bot.connector.ConnectorHandler +import ai.tock.bot.definition.AsyncStoryHandling +import ai.tock.bot.definition.ConnectorSpecificHandling import ai.tock.bot.definition.ConnectorStoryHandler +import ai.tock.bot.definition.StoryHandlerDefinitionBase import kotlin.reflect.KClass /** * To specify [ConnectorStoryHandler] for Alexa connector. * [KClass] passed as [value] of this annotation must have a primary constructor - * with a single not optional [StoryHandlerDefinitionBase] argument. + * with a single not optional [StoryHandlerDefinitionBase] or [AsyncStoryHandling] argument. */ @ConnectorHandler(connectorTypeId = ALEXA_CONNECTOR_TYPE_ID) @Target(AnnotationTarget.CLASS) @MustBeDocumented -annotation class AlexaHandler(val value: KClass>) +annotation class AlexaHandler(val value: KClass) diff --git a/bot/connector-businesschat/src/main/kotlin/BusinessChatHandler.kt b/bot/connector-businesschat/src/main/kotlin/BusinessChatHandler.kt index e6e3cae2cb..99c0494049 100644 --- a/bot/connector-businesschat/src/main/kotlin/BusinessChatHandler.kt +++ b/bot/connector-businesschat/src/main/kotlin/BusinessChatHandler.kt @@ -17,18 +17,18 @@ package ai.tock.bot.connector.businesschat import ai.tock.bot.connector.ConnectorHandler +import ai.tock.bot.definition.AsyncStoryHandling +import ai.tock.bot.definition.ConnectorSpecificHandling import ai.tock.bot.definition.ConnectorStoryHandler -import kotlin.annotation.AnnotationTarget -import kotlin.annotation.MustBeDocumented -import kotlin.annotation.Target +import ai.tock.bot.definition.StoryHandlerDefinitionBase import kotlin.reflect.KClass /** * To specify [ConnectorStoryHandler] for BusinessChat connector. * [KClass] passed as [value] of this annotation must have a primary constructor - * with a single not optional [StoryHandlerDefinitionBase] argument. + * with a single not optional [StoryHandlerDefinitionBase] or [AsyncStoryHandling] argument. */ @ConnectorHandler(connectorTypeId = BUSINESS_CHAT_CONNECTOR_TYPE_ID) @Target(AnnotationTarget.CLASS) @MustBeDocumented -annotation class BusinessChatHandler(val value: KClass>) +annotation class BusinessChatHandler(val value: KClass) diff --git a/bot/connector-ga/src/main/kotlin/GABuilders.kt b/bot/connector-ga/src/main/kotlin/GABuilders.kt index 0225b91929..7c93ec2f33 100644 --- a/bot/connector-ga/src/main/kotlin/GABuilders.kt +++ b/bot/connector-ga/src/main/kotlin/GABuilders.kt @@ -41,8 +41,7 @@ import ai.tock.bot.connector.ga.model.response.GASimpleSelect import ai.tock.bot.connector.ga.model.response.GAStructuredResponse import ai.tock.bot.connector.ga.model.response.GAUpdatePermissionValueSpec import ai.tock.bot.definition.IntentAware -import ai.tock.bot.definition.StoryHandlerDefinition -import ai.tock.bot.definition.StoryStep +import ai.tock.bot.definition.StoryStepDef import ai.tock.bot.engine.Bus import ai.tock.bot.engine.I18nTranslator import ai.tock.bot.engine.action.SendChoice @@ -305,7 +304,7 @@ fun I18nTranslator.gaButton(title: CharSequence, url: String): GAButton { fun > T.optionInfo( title: CharSequence, targetIntent: IntentAware, - step: StoryStep? = null, + step: StoryStepDef? = null, vararg parameters: Pair ): GAOptionInfo { val t = translate(title) diff --git a/bot/connector-ga/src/main/kotlin/GACarouselBuilders.kt b/bot/connector-ga/src/main/kotlin/GACarouselBuilders.kt index 597aca23b4..9593eb49d2 100644 --- a/bot/connector-ga/src/main/kotlin/GACarouselBuilders.kt +++ b/bot/connector-ga/src/main/kotlin/GACarouselBuilders.kt @@ -24,8 +24,8 @@ import ai.tock.bot.connector.ga.model.response.GAExpectedIntent import ai.tock.bot.connector.ga.model.response.GAImage import ai.tock.bot.definition.IntentAware import ai.tock.bot.definition.Parameters -import ai.tock.bot.definition.StoryHandlerDefinition import ai.tock.bot.definition.StoryStep +import ai.tock.bot.definition.StoryStepDef import ai.tock.bot.engine.Bus import ai.tock.bot.engine.I18nTranslator import ai.tock.translator.raw @@ -163,7 +163,7 @@ fun > T.carouselItem( */ fun > T.carouselItem( targetIntent: IntentAware, - step: StoryStep?, + step: StoryStepDef?, title: CharSequence, description: CharSequence? = null, image: GAImage? = null, @@ -176,7 +176,7 @@ fun > T.carouselItem( */ fun > T.carouselItem( targetIntent: IntentAware, - step: StoryStep?, + step: StoryStepDef?, title: CharSequence, description: CharSequence? = null, image: GAImage? = null, diff --git a/bot/connector-ga/src/main/kotlin/GAHandler.kt b/bot/connector-ga/src/main/kotlin/GAHandler.kt index fccaa00418..da4e2a3b74 100644 --- a/bot/connector-ga/src/main/kotlin/GAHandler.kt +++ b/bot/connector-ga/src/main/kotlin/GAHandler.kt @@ -17,6 +17,7 @@ package ai.tock.bot.connector.ga import ai.tock.bot.connector.ConnectorHandler +import ai.tock.bot.definition.ConnectorSpecificHandling import ai.tock.bot.definition.ConnectorStoryHandler import kotlin.reflect.KClass @@ -28,4 +29,4 @@ import kotlin.reflect.KClass @ConnectorHandler(connectorTypeId = GA_CONNECTOR_TYPE_ID) @Target(AnnotationTarget.CLASS) @MustBeDocumented -annotation class GAHandler(val value: KClass>) +annotation class GAHandler(val value: KClass) diff --git a/bot/connector-ga/src/main/kotlin/GAListBuilders.kt b/bot/connector-ga/src/main/kotlin/GAListBuilders.kt index 300d27a612..48bad6ad83 100644 --- a/bot/connector-ga/src/main/kotlin/GAListBuilders.kt +++ b/bot/connector-ga/src/main/kotlin/GAListBuilders.kt @@ -25,8 +25,8 @@ import ai.tock.bot.connector.ga.model.response.GARichResponse import ai.tock.bot.connector.ga.model.response.GASuggestion import ai.tock.bot.definition.IntentAware import ai.tock.bot.definition.Parameters -import ai.tock.bot.definition.StoryHandlerDefinition import ai.tock.bot.definition.StoryStep +import ai.tock.bot.definition.StoryStepDef import ai.tock.bot.engine.Bus import ai.tock.bot.engine.I18nTranslator import ai.tock.translator.raw @@ -169,7 +169,7 @@ fun > T.listItem( fun > T.listItem( title: CharSequence, targetIntent: IntentAware, - step: StoryStep?, + step: StoryStepDef?, parameters: Parameters ): GAListItem = listItem(title, targetIntent, step, null, null, parameters) @@ -179,7 +179,7 @@ fun > T.listItem( fun > T.listItem( title: CharSequence, targetIntent: IntentAware, - step: StoryStep?, + step: StoryStepDef?, vararg parameters: Pair ): GAListItem = listItem(title, targetIntent, step, null, null, *parameters) @@ -189,7 +189,7 @@ fun > T.listItem( fun > T.listItem( title: CharSequence, targetIntent: IntentAware, - step: StoryStep?, + step: StoryStepDef?, description: CharSequence? = null, imageUrl: String? = null, parameters: Parameters @@ -201,7 +201,7 @@ fun > T.listItem( fun > T.listItem( title: CharSequence, targetIntent: IntentAware, - step: StoryStep?, + step: StoryStepDef?, description: CharSequence? = null, imageUrl: String? = null, vararg parameters: Pair diff --git a/bot/connector-ga/src/main/kotlin/GASelectBuilders.kt b/bot/connector-ga/src/main/kotlin/GASelectBuilders.kt index d981ad1b47..00367e50b4 100644 --- a/bot/connector-ga/src/main/kotlin/GASelectBuilders.kt +++ b/bot/connector-ga/src/main/kotlin/GASelectBuilders.kt @@ -22,8 +22,8 @@ import ai.tock.bot.connector.ga.model.response.GASelectItem import ai.tock.bot.connector.ga.model.response.GASimpleSelect import ai.tock.bot.definition.IntentAware import ai.tock.bot.definition.Parameters -import ai.tock.bot.definition.StoryHandlerDefinition import ai.tock.bot.definition.StoryStep +import ai.tock.bot.definition.StoryStepDef import ai.tock.bot.engine.Bus import ai.tock.bot.engine.I18nTranslator @@ -64,7 +64,7 @@ fun > T.selectItem( fun > T.selectItem( title: CharSequence, targetIntent: IntentAware, - step: StoryStep, + step: StoryStepDef, optionTitle: CharSequence? = null, parameters: Parameters ): GASelectItem = selectItem(title, targetIntent, step, optionTitle, *parameters.toArray()) @@ -75,7 +75,7 @@ fun > T.selectItem( fun > T.selectItem( title: CharSequence, targetIntent: IntentAware, - step: StoryStep? = null, + step: StoryStepDef? = null, optionTitle: CharSequence? = null, vararg parameters: Pair ): GASelectItem { diff --git a/bot/connector-google-chat/src/main/kotlin/GoogleChatHandler.kt b/bot/connector-google-chat/src/main/kotlin/GoogleChatHandler.kt index b1ecb6c89e..c2a96a7fd4 100644 --- a/bot/connector-google-chat/src/main/kotlin/GoogleChatHandler.kt +++ b/bot/connector-google-chat/src/main/kotlin/GoogleChatHandler.kt @@ -17,6 +17,7 @@ package ai.tock.bot.connector.googlechat import ai.tock.bot.connector.ConnectorHandler import ai.tock.bot.connector.googlechat.builder.GOOGLE_CHAT_CONNECTOR_TYPE_ID +import ai.tock.bot.definition.ConnectorSpecificHandling import ai.tock.bot.definition.ConnectorStoryHandler import ai.tock.bot.definition.StoryHandlerDefinition import kotlin.reflect.KClass @@ -29,4 +30,4 @@ import kotlin.reflect.KClass @ConnectorHandler(connectorTypeId = GOOGLE_CHAT_CONNECTOR_TYPE_ID) @Target(AnnotationTarget.CLASS) @MustBeDocumented -annotation class GoogleChatHandler(val value: KClass>) +annotation class GoogleChatHandler(val value: KClass) diff --git a/bot/connector-messenger/src/main/kotlin/MessengerBuilders.kt b/bot/connector-messenger/src/main/kotlin/MessengerBuilders.kt index 63ff862577..cd0ee46bf5 100644 --- a/bot/connector-messenger/src/main/kotlin/MessengerBuilders.kt +++ b/bot/connector-messenger/src/main/kotlin/MessengerBuilders.kt @@ -45,8 +45,7 @@ import ai.tock.bot.connector.messenger.model.send.UserAction.Companion.extractQu import ai.tock.bot.definition.Intent import ai.tock.bot.definition.IntentAware import ai.tock.bot.definition.Parameters -import ai.tock.bot.definition.StoryHandlerDefinition -import ai.tock.bot.definition.StoryStep +import ai.tock.bot.definition.StoryStepDef import ai.tock.bot.engine.Bus import ai.tock.bot.engine.I18nTranslator import ai.tock.bot.engine.action.ActionMetadata @@ -373,7 +372,7 @@ fun I18nTranslator.standaloneQuickReply( /** * The target step. */ - step: StoryStep? = null, + step: StoryStepDef? = null, /** * The image url of the quick reply. */ @@ -381,7 +380,7 @@ fun I18nTranslator.standaloneQuickReply( /** * The current step of the Bus. */ - busStep: StoryStep? = null, + busStep: StoryStepDef? = null, /** * The current intent of the Bus. */ @@ -412,7 +411,7 @@ fun > T.quickReply( title: CharSequence, targetIntent: IntentAware, imageUrl: String? = null, - step: StoryStep? = null, + step: StoryStepDef? = null, parameters: Parameters ): QuickReply = quickReply(title, targetIntent, imageUrl, step, parameters.toMap()) @@ -424,7 +423,7 @@ fun > T.quickReply( title: CharSequence, targetIntent: IntentAware, imageUrl: String? = null, - step: StoryStep? = null, + step: StoryStepDef? = null, vararg parameters: Pair ): QuickReply = quickReply(title, targetIntent.wrappedIntent(), imageUrl, step, parameters.toMap()) @@ -436,7 +435,7 @@ fun > T.quickReply( title: CharSequence, targetIntent: IntentAware, imageUrl: String? = null, - step: StoryStep? = null, + step: StoryStepDef? = null, parameters: Collection> ): QuickReply = quickReply(title, targetIntent, imageUrl, step, parameters.toMap()) @@ -448,7 +447,7 @@ fun > T.quickReply( title: CharSequence, targetIntent: IntentAware, imageUrl: String? = null, - step: StoryStep? = null, + step: StoryStepDef? = null, parameters: Map ): QuickReply = quickReply(title, targetIntent, imageUrl, step?.name, parameters) @@ -515,7 +514,7 @@ fun > T.postbackButton( fun > T.postbackButton( title: CharSequence, targetIntent: IntentAware, - step: StoryStep? = null, + step: StoryStepDef? = null, parameters: Parameters ): PostbackButton = postbackButton(title, targetIntent, step, *parameters.toArray()) @@ -526,7 +525,7 @@ fun > T.postbackButton( fun > T.postbackButton( title: CharSequence, targetIntent: IntentAware, - step: StoryStep? = null, + step: StoryStepDef? = null, vararg parameters: Pair ): PostbackButton = postbackButton( @@ -541,9 +540,9 @@ fun > T.postbackButton( private fun I18nTranslator.postbackButton( title: CharSequence, targetIntent: IntentAware, - step: StoryStep? = null, + step: StoryStepDef? = null, parameters: Map, - payloadEncoder: (IntentAware, StoryStep?, Map) -> String + payloadEncoder: (IntentAware, StoryStepDef?, Map) -> String ): PostbackButton { val t = translate(title) if (t.length > 20) { diff --git a/bot/connector-messenger/src/main/kotlin/MessengerConnector.kt b/bot/connector-messenger/src/main/kotlin/MessengerConnector.kt index 7e43033a18..fb9f4b5d97 100644 --- a/bot/connector-messenger/src/main/kotlin/MessengerConnector.kt +++ b/bot/connector-messenger/src/main/kotlin/MessengerConnector.kt @@ -46,8 +46,7 @@ import ai.tock.bot.connector.messenger.model.send.SenderAction.typing_on import ai.tock.bot.connector.messenger.model.send.UrlPayload import ai.tock.bot.connector.messenger.model.webhook.CallbackRequest import ai.tock.bot.definition.IntentAware -import ai.tock.bot.definition.StoryHandlerDefinition -import ai.tock.bot.definition.StoryStep +import ai.tock.bot.definition.StoryStepDef import ai.tock.bot.engine.BotBus import ai.tock.bot.engine.BotRepository.requestTimer import ai.tock.bot.engine.ConnectorController @@ -73,9 +72,6 @@ import ai.tock.shared.property import ai.tock.shared.vertx.vertx import com.fasterxml.jackson.module.kotlin.readValue import com.github.salomonbrys.kodein.instance -import mu.KotlinLogging -import org.apache.commons.codec.binary.Hex -import org.apache.commons.lang3.LocaleUtils import java.time.Duration import java.time.ZoneOffset import java.util.Locale @@ -83,6 +79,9 @@ import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.CopyOnWriteArraySet import javax.crypto.Mac import javax.crypto.spec.SecretKeySpec +import mu.KotlinLogging +import org.apache.commons.codec.binary.Hex +import org.apache.commons.lang3.LocaleUtils /** * Contains built-in checks to ensure that two [MessageRequest] for the same recipient are sent sequentially. @@ -587,7 +586,7 @@ class MessengerConnector internal constructor( controller: ConnectorController, recipientId: PlayerId, intent: IntentAware, - step: StoryStep?, + step: StoryStepDef?, parameters: Map, notificationType: ActionNotificationType?, errorListener: (Throwable) -> Unit diff --git a/bot/connector-messenger/src/main/kotlin/MessengerHandler.kt b/bot/connector-messenger/src/main/kotlin/MessengerHandler.kt index 39369e67fa..925cff47d8 100644 --- a/bot/connector-messenger/src/main/kotlin/MessengerHandler.kt +++ b/bot/connector-messenger/src/main/kotlin/MessengerHandler.kt @@ -17,6 +17,7 @@ package ai.tock.bot.connector.messenger import ai.tock.bot.connector.ConnectorHandler +import ai.tock.bot.definition.ConnectorSpecificHandling import ai.tock.bot.definition.ConnectorStoryHandler import kotlin.reflect.KClass @@ -29,5 +30,5 @@ import kotlin.reflect.KClass @Target(AnnotationTarget.CLASS) @MustBeDocumented annotation class MessengerHandler( - val value: KClass> + val value: KClass ) diff --git a/bot/connector-slack/src/main/kotlin/SlackBuilders.kt b/bot/connector-slack/src/main/kotlin/SlackBuilders.kt index 9b61b5cae9..6b3135fd49 100644 --- a/bot/connector-slack/src/main/kotlin/SlackBuilders.kt +++ b/bot/connector-slack/src/main/kotlin/SlackBuilders.kt @@ -26,8 +26,7 @@ import ai.tock.bot.connector.slack.model.SlackMessageAttachment import ai.tock.bot.connector.slack.model.SlackMessageOut import ai.tock.bot.definition.IntentAware import ai.tock.bot.definition.Parameters -import ai.tock.bot.definition.StoryHandlerDefinition -import ai.tock.bot.definition.StoryStep +import ai.tock.bot.definition.StoryStepDef import ai.tock.bot.engine.Bus import ai.tock.bot.engine.I18nTranslator import ai.tock.bot.engine.action.SendChoice @@ -142,7 +141,7 @@ fun > T.slackButton( fun > T.slackButton( title: CharSequence, targetIntent: IntentAware?, - step: StoryStep? = null, + step: StoryStepDef? = null, parameters: Parameters = Parameters(), name: String = "default" ): Button = @@ -154,7 +153,7 @@ fun > T.slackButton( fun > T.slackButton( title: CharSequence, targetIntent: IntentAware?, - step: StoryStep? = null, + step: StoryStepDef? = null, vararg parameters: Pair, name: String = "default" ): Button { diff --git a/bot/connector-slack/src/main/kotlin/SlackHandler.kt b/bot/connector-slack/src/main/kotlin/SlackHandler.kt index 2ae0fc3044..5d7ace136a 100644 --- a/bot/connector-slack/src/main/kotlin/SlackHandler.kt +++ b/bot/connector-slack/src/main/kotlin/SlackHandler.kt @@ -17,6 +17,7 @@ package ai.tock.bot.connector.slack import ai.tock.bot.connector.ConnectorHandler +import ai.tock.bot.definition.ConnectorSpecificHandling import ai.tock.bot.definition.ConnectorStoryHandler import kotlin.reflect.KClass @@ -28,4 +29,4 @@ import kotlin.reflect.KClass @ConnectorHandler(connectorTypeId = SLACK_CONNECTOR_TYPE_ID) @Target(AnnotationTarget.CLASS) @MustBeDocumented -annotation class SlackHandler(val value: KClass>) +annotation class SlackHandler(val value: KClass) diff --git a/bot/connector-teams/src/main/kotlin/TeamsHandler.kt b/bot/connector-teams/src/main/kotlin/TeamsHandler.kt index c965c68934..640049a453 100644 --- a/bot/connector-teams/src/main/kotlin/TeamsHandler.kt +++ b/bot/connector-teams/src/main/kotlin/TeamsHandler.kt @@ -17,6 +17,7 @@ package ai.tock.bot.connector.teams import ai.tock.bot.connector.ConnectorHandler +import ai.tock.bot.definition.ConnectorSpecificHandling import ai.tock.bot.definition.ConnectorStoryHandler import kotlin.reflect.KClass @@ -28,4 +29,4 @@ import kotlin.reflect.KClass @ConnectorHandler(connectorTypeId = TEAMS_CONNECTOR_TYPE_ID) @Target(AnnotationTarget.CLASS) @MustBeDocumented -annotation class TeamsHandler(val value: KClass>) +annotation class TeamsHandler(val value: KClass) diff --git a/bot/connector-twitter/src/main/kotlin/TwitterConnector.kt b/bot/connector-twitter/src/main/kotlin/TwitterConnector.kt index b8bb4e561c..932fc2f743 100755 --- a/bot/connector-twitter/src/main/kotlin/TwitterConnector.kt +++ b/bot/connector-twitter/src/main/kotlin/TwitterConnector.kt @@ -35,8 +35,7 @@ import ai.tock.bot.connector.twitter.model.outcoming.OutcomingEvent import ai.tock.bot.connector.twitter.model.outcoming.Tweet import ai.tock.bot.connector.twitter.model.toMediaCategory import ai.tock.bot.definition.IntentAware -import ai.tock.bot.definition.StoryHandlerDefinition -import ai.tock.bot.definition.StoryStep +import ai.tock.bot.definition.StoryStepDef import ai.tock.bot.engine.BotBus import ai.tock.bot.engine.BotRepository import ai.tock.bot.engine.ConnectorController @@ -59,9 +58,9 @@ import ai.tock.shared.jackson.mapper import ai.tock.translator.raw import com.fasterxml.jackson.module.kotlin.readValue import com.github.salomonbrys.kodein.instance +import java.time.ZoneOffset import mu.KotlinLogging import org.apache.commons.lang3.LocaleUtils -import java.time.ZoneOffset internal class TwitterConnector internal constructor( val applicationId: String, @@ -252,7 +251,7 @@ internal class TwitterConnector internal constructor( controller: ConnectorController, recipientId: PlayerId, intent: IntentAware, - step: StoryStep?, + step: StoryStepDef?, parameters: Map, notificationType: ActionNotificationType?, errorListener: (Throwable) -> Unit diff --git a/bot/connector-web/src/main/kotlin/WebBuilders.kt b/bot/connector-web/src/main/kotlin/WebBuilders.kt index f4835a76d7..7af379ca1f 100644 --- a/bot/connector-web/src/main/kotlin/WebBuilders.kt +++ b/bot/connector-web/src/main/kotlin/WebBuilders.kt @@ -31,8 +31,7 @@ import ai.tock.bot.connector.web.send.WebImage import ai.tock.bot.connector.web.send.WebWidget import ai.tock.bot.definition.IntentAware import ai.tock.bot.definition.Parameters -import ai.tock.bot.definition.StoryHandlerDefinition -import ai.tock.bot.definition.StoryStep +import ai.tock.bot.definition.StoryStepDef import ai.tock.bot.engine.Bus import ai.tock.bot.engine.I18nTranslator import ai.tock.bot.engine.action.SendAttachment.AttachmentType @@ -88,7 +87,7 @@ fun > T.webButton( title: CharSequence, targetIntent: IntentAware? = null, imageUrl: String? = null, - step: StoryStep? = null, + step: StoryStepDef? = null, parameters: Parameters = Parameters() ): WebButton = WebButton( @@ -194,7 +193,7 @@ fun > T.webUrlButton( fun > T.webPostbackButton( title: CharSequence, targetIntent: IntentAware, - step: StoryStep? = null, + step: StoryStepDef? = null, parameters: Parameters = Parameters(), imageUrl: String? = null, style: ButtonStyle @@ -207,7 +206,7 @@ fun > T.webPostbackButton( fun > T.webPostbackButton( title: CharSequence, targetIntent: IntentAware, - step: StoryStep? = null, + step: StoryStepDef? = null, parameters: Parameters = Parameters(), imageUrl: String? = null, style: String? = ButtonStyle.primary.name @@ -226,7 +225,7 @@ fun > T.webPostbackButton( fun > T.webQuickReply( title: CharSequence, targetIntent: IntentAware? = null, - step: StoryStep? = null, + step: StoryStepDef? = null, parameters: Parameters = Parameters(), imageUrl: String? = null ): Button = @@ -242,7 +241,7 @@ fun > T.webQuickReply( fun > T.webIntentQuickReply( title: CharSequence, targetIntent: IntentAware, - step: StoryStep? = null, + step: StoryStepDef? = null, parameters: Parameters = Parameters(), imageUrl: String? = null, style: ButtonStyle @@ -255,7 +254,7 @@ fun > T.webIntentQuickReply( fun > T.webIntentQuickReply( title: CharSequence, targetIntent: IntentAware, - step: StoryStep? = null, + step: StoryStepDef? = null, parameters: Parameters = Parameters(), imageUrl: String? = null, style: String? = ButtonStyle.primary.name @@ -385,7 +384,7 @@ fun > T.webCard(card: MediaCard): OldWebMessage = OldWebMessage(card /** * Creates a [OldWebMessage] from a [MediaCarousel]. */ -@Deprecated("No more supported", ReplaceWith("webCarousel(vararg cards: WebCard)")) +@Deprecated("No more supported", ReplaceWith("webCarousel(*cards)")) fun > T.webCarousel(carousel: MediaCarousel): OldWebMessage = OldWebMessage(carousel = carousel) /** diff --git a/bot/connector-web/src/main/kotlin/WebConnector.kt b/bot/connector-web/src/main/kotlin/WebConnector.kt index 379ecb86d1..238d51cd61 100644 --- a/bot/connector-web/src/main/kotlin/WebConnector.kt +++ b/bot/connector-web/src/main/kotlin/WebConnector.kt @@ -16,8 +16,12 @@ package ai.tock.bot.connector.web -import ai.tock.bot.connector.* +import ai.tock.bot.connector.ConnectorBase +import ai.tock.bot.connector.ConnectorCallback +import ai.tock.bot.connector.ConnectorData import ai.tock.bot.connector.ConnectorFeature.CAROUSEL +import ai.tock.bot.connector.ConnectorMessage +import ai.tock.bot.connector.ConnectorType import ai.tock.bot.connector.media.MediaAction import ai.tock.bot.connector.media.MediaCard import ai.tock.bot.connector.media.MediaCarousel @@ -29,8 +33,7 @@ import ai.tock.bot.connector.web.send.UrlButton import ai.tock.bot.connector.web.send.WebCard import ai.tock.bot.connector.web.send.WebCarousel import ai.tock.bot.definition.IntentAware -import ai.tock.bot.definition.StoryHandlerDefinition -import ai.tock.bot.definition.StoryStep +import ai.tock.bot.definition.StoryStepDef import ai.tock.bot.engine.BotBus import ai.tock.bot.engine.BotRepository import ai.tock.bot.engine.ConnectorController @@ -51,8 +54,14 @@ import ai.tock.bot.orchestration.shared.AskEligibilityToOrchestratedBotRequest import ai.tock.bot.orchestration.shared.OrchestrationMetaData import ai.tock.bot.orchestration.shared.ResumeOrchestrationRequest import ai.tock.bot.orchestration.shared.SecondaryBotEligibilityResponse -import ai.tock.shared.* +import ai.tock.shared.Dice +import ai.tock.shared.booleanProperty +import ai.tock.shared.defaultLocale import ai.tock.shared.jackson.mapper +import ai.tock.shared.listProperty +import ai.tock.shared.longProperty +import ai.tock.shared.property +import ai.tock.shared.propertyOrNull import ai.tock.shared.security.auth.spi.TOCK_USER_ID import ai.tock.shared.security.auth.spi.WebSecurityHandler import ai.tock.shared.vertx.sendSseMessage @@ -67,9 +76,10 @@ import io.vertx.core.http.HttpServerResponse import io.vertx.core.json.JsonObject import io.vertx.ext.web.RoutingContext import io.vertx.ext.web.handler.CorsHandler -import mu.KotlinLogging import java.time.Duration -import java.util.* +import java.util.Locale +import java.util.UUID +import mu.KotlinLogging internal const val WEB_CONNECTOR_ID = "web" /** @@ -266,7 +276,7 @@ class WebConnector internal constructor( controller: ConnectorController, recipientId: PlayerId, intent: IntentAware, - step: StoryStep?, + step: StoryStepDef?, parameters: Map, notificationType: ActionNotificationType?, errorListener: (Throwable) -> Unit diff --git a/bot/connector-web/src/main/kotlin/WebHandler.kt b/bot/connector-web/src/main/kotlin/WebHandler.kt index 02f2faa573..c560f72e35 100644 --- a/bot/connector-web/src/main/kotlin/WebHandler.kt +++ b/bot/connector-web/src/main/kotlin/WebHandler.kt @@ -17,15 +17,18 @@ package ai.tock.bot.connector.web import ai.tock.bot.connector.ConnectorHandler +import ai.tock.bot.definition.AsyncStoryHandling +import ai.tock.bot.definition.ConnectorSpecificHandling import ai.tock.bot.definition.ConnectorStoryHandler +import ai.tock.bot.definition.StoryHandlerDefinitionBase import kotlin.reflect.KClass /** * To specify [ConnectorStoryHandler] for Web connector. * [KClass] passed as [value] of this annotation must have a primary constructor - * with a single not optional [StoryHandlerDefinitionBase] argument. + * with a single not optional [StoryHandlerDefinitionBase] or [AsyncStoryHandling] argument. */ @ConnectorHandler(connectorTypeId = WEB_CONNECTOR_ID) @Target(AnnotationTarget.CLASS) @MustBeDocumented -annotation class WebHandler(val value: KClass>) +annotation class WebHandler(val value: KClass) diff --git a/bot/connector-whatsapp-cloud/src/main/kotlin/WhatsAppCloudBuilder.kt b/bot/connector-whatsapp-cloud/src/main/kotlin/WhatsAppCloudBuilder.kt index f2e22fde21..5f0d67e507 100644 --- a/bot/connector-whatsapp-cloud/src/main/kotlin/WhatsAppCloudBuilder.kt +++ b/bot/connector-whatsapp-cloud/src/main/kotlin/WhatsAppCloudBuilder.kt @@ -52,8 +52,7 @@ import ai.tock.bot.connector.whatsapp.cloud.model.send.message.content.WhatsAppC import ai.tock.bot.connector.whatsapp.cloud.model.send.message.content.WhatsappTemplateComponent import ai.tock.bot.definition.IntentAware import ai.tock.bot.definition.Parameters -import ai.tock.bot.definition.StoryHandlerDefinition -import ai.tock.bot.definition.StoryStep +import ai.tock.bot.definition.StoryStepDef import ai.tock.bot.engine.BotBus import ai.tock.bot.engine.Bus import ai.tock.bot.engine.I18nTranslator @@ -126,7 +125,7 @@ fun > T.withWhatsAppCloud(messageProvider: () -> WhatsAppCloudConnect * @param text the text sent * @param previewUrl if set to `true`, WhatsApp will render a preview of the first URL in the message's [text] */ -fun BotBus.whatsAppCloudText( +fun Bus<*>.whatsAppCloudText( text: CharSequence, previewUrl: Boolean = false ): WhatsAppCloudBotTextMessage = @@ -144,7 +143,7 @@ fun BotBus.whatsAppCloudText( * @param caption a caption to display below the image * @param uploadToWhatsapp if `true`, the image will be uploaded to Meta's servers (recommended) */ -fun BotBus.whatsAppCloudImage( +fun Bus<*>.whatsAppCloudImage( url: String, caption: CharSequence? = null, uploadToWhatsapp: Boolean = uploadImagesToWhatsapp, @@ -166,7 +165,7 @@ fun BotBus.whatsAppCloudImage( * @param imageBytes a byte array containing the image data * @param caption a caption to display below the image */ -fun BotBus.whatsAppCloudImage( +fun Bus<*>.whatsAppCloudImage( id: String, imageBytes: ByteArray, caption: CharSequence? = null, @@ -583,7 +582,7 @@ fun > T.whatsAppCloudQuickReply( fun > T.whatsAppCloudQuickReply( title: CharSequence, targetIntent: IntentAware, - step: StoryStep? = null, + step: StoryStepDef? = null, vararg parameters: Pair ): QuickReply = whatsAppCloudQuickReply(title, targetIntent.wrappedIntent(), step?.name, parameters.toMap()) @@ -609,7 +608,7 @@ fun > T.whatsAppCloudQuickReply( fun > T.whatsAppCloudQuickReply( title: CharSequence, targetIntent: IntentAware, - step: StoryStep<*>? = null, + step: StoryStepDef? = null, parameters: Map = mapOf() ): QuickReply = whatsAppCloudQuickReply(title, null, targetIntent, step?.name, parameters) { intent, s, params -> @@ -640,7 +639,7 @@ fun > T.whatsAppCloudQuickReply( title: CharSequence, subTitle: CharSequence? = null, targetIntent: IntentAware, - step: StoryStep<*>? = null, + step: StoryStepDef? = null, parameters: Map = mapOf() ): QuickReply = whatsAppCloudQuickReply(title,subTitle, targetIntent, step?.name, parameters) { intent, s, params -> @@ -815,7 +814,7 @@ fun > T.whatsAppCloudPostbackButton( index: String, title: CharSequence, targetIntent: IntentAware, - step: StoryStep? = null, + step: StoryStepDef? = null, parameters: Parameters = Parameters() ) = whatsAppCloudPostbackButton(index.toInt(), title, targetIntent, step, parameters) @@ -823,7 +822,7 @@ fun > T.whatsAppCloudPostbackButton( index: Int, title: CharSequence, targetIntent: IntentAware, - step: StoryStep? = null, + step: StoryStepDef? = null, parameters: Parameters = Parameters() ): WhatsappTemplateComponent.Button = whatsAppCloudPostbackButton( index = index, diff --git a/bot/connector-whatsapp-cloud/src/main/kotlin/WhatsAppCloudHandler.kt b/bot/connector-whatsapp-cloud/src/main/kotlin/WhatsAppCloudHandler.kt index be6c22ed5c..e5aebc13eb 100644 --- a/bot/connector-whatsapp-cloud/src/main/kotlin/WhatsAppCloudHandler.kt +++ b/bot/connector-whatsapp-cloud/src/main/kotlin/WhatsAppCloudHandler.kt @@ -17,12 +17,18 @@ package ai.tock.bot.connector.whatsapp.cloud import ai.tock.bot.connector.ConnectorHandler +import ai.tock.bot.definition.AsyncStoryHandling +import ai.tock.bot.definition.ConnectorSpecificHandling import ai.tock.bot.definition.ConnectorStoryHandler +import ai.tock.bot.definition.StoryHandlerDefinitionBase import kotlin.reflect.KClass +/** + * To specify [ConnectorStoryHandler] for WhatsApp connector. + * [KClass] passed as [value] of this annotation must have a primary constructor + * with a single not optional [StoryHandlerDefinitionBase] or [AsyncStoryHandling] argument. + */ @ConnectorHandler(connectorTypeId = "whatsapp_cloud") @Target(AnnotationTarget.CLASS) @MustBeDocumented -annotation class WhatsAppCloudHandler( - val value: KClass> -) +annotation class WhatsAppCloudHandler(val value: KClass) diff --git a/bot/connector-whatsapp-cloud/src/main/kotlin/WhatsAppConnectorCloudConnector.kt b/bot/connector-whatsapp-cloud/src/main/kotlin/WhatsAppConnectorCloudConnector.kt index ac42094c4b..0a24ed7185 100644 --- a/bot/connector-whatsapp-cloud/src/main/kotlin/WhatsAppConnectorCloudConnector.kt +++ b/bot/connector-whatsapp-cloud/src/main/kotlin/WhatsAppConnectorCloudConnector.kt @@ -32,8 +32,7 @@ import ai.tock.bot.connector.whatsapp.cloud.spi.TemplateGenerationContext import ai.tock.bot.connector.whatsapp.cloud.spi.TemplateManagementContext import ai.tock.bot.connector.whatsapp.cloud.spi.WhatsappTemplateProvider import ai.tock.bot.definition.IntentAware -import ai.tock.bot.definition.StoryHandlerDefinition -import ai.tock.bot.definition.StoryStep +import ai.tock.bot.definition.StoryStepDef import ai.tock.bot.engine.BotBus import ai.tock.bot.engine.BotRepository import ai.tock.bot.engine.ConnectorController @@ -247,7 +246,7 @@ class WhatsAppConnectorCloudConnector internal constructor( controller: ConnectorController, recipientId: PlayerId, intent: IntentAware, - step: StoryStep?, + step: StoryStepDef?, parameters: Map, notificationType: ActionNotificationType?, errorListener: (Throwable) -> Unit diff --git a/bot/connector-whatsapp/src/main/kotlin/WhatsAppBuilder.kt b/bot/connector-whatsapp/src/main/kotlin/WhatsAppBuilder.kt index 9bd4039277..ac0dcd9444 100644 --- a/bot/connector-whatsapp/src/main/kotlin/WhatsAppBuilder.kt +++ b/bot/connector-whatsapp/src/main/kotlin/WhatsAppBuilder.kt @@ -39,8 +39,7 @@ import ai.tock.bot.connector.whatsapp.model.webhook.WhatsAppLanguage import ai.tock.bot.connector.whatsapp.model.webhook.WhatsAppTemplate import ai.tock.bot.definition.IntentAware import ai.tock.bot.definition.Parameters -import ai.tock.bot.definition.StoryHandlerDefinition -import ai.tock.bot.definition.StoryStep +import ai.tock.bot.definition.StoryStepDef import ai.tock.bot.engine.BotBus import ai.tock.bot.engine.Bus import ai.tock.bot.engine.I18nTranslator @@ -329,7 +328,7 @@ fun > T.quickReply( title: CharSequence, subTitle: CharSequence? = null, targetIntent: IntentAware, - step: StoryStep? = null, + step: StoryStepDef? = null, vararg parameters: Pair ) : QuickReply = quickReply(title, subTitle, targetIntent.wrappedIntent(), step?.name, parameters.toMap()) diff --git a/bot/connector-whatsapp/src/main/kotlin/WhatsAppHandler.kt b/bot/connector-whatsapp/src/main/kotlin/WhatsAppHandler.kt index 3abf1d0677..382d26167b 100644 --- a/bot/connector-whatsapp/src/main/kotlin/WhatsAppHandler.kt +++ b/bot/connector-whatsapp/src/main/kotlin/WhatsAppHandler.kt @@ -17,6 +17,7 @@ package ai.tock.bot.connector.whatsapp import ai.tock.bot.connector.ConnectorHandler +import ai.tock.bot.definition.ConnectorSpecificHandling import ai.tock.bot.definition.ConnectorStoryHandler import kotlin.reflect.KClass @@ -28,4 +29,4 @@ import kotlin.reflect.KClass @ConnectorHandler(connectorTypeId = WHATS_APP_CONNECTOR_TYPE_ID) @Target(AnnotationTarget.CLASS) @MustBeDocumented -annotation class WhatsAppHandler(val value: KClass>) +annotation class WhatsAppHandler(val value: KClass) diff --git a/bot/engine/src/main/kotlin/admin/story/StoryDefinitionConfigurationStep.kt b/bot/engine/src/main/kotlin/admin/story/StoryDefinitionConfigurationStep.kt index a0fd5e3088..0e98d524a0 100644 --- a/bot/engine/src/main/kotlin/admin/story/StoryDefinitionConfigurationStep.kt +++ b/bot/engine/src/main/kotlin/admin/story/StoryDefinitionConfigurationStep.kt @@ -23,8 +23,8 @@ import ai.tock.bot.definition.EntityStepSelection import ai.tock.bot.definition.IntentAware import ai.tock.bot.definition.IntentWithoutNamespace import ai.tock.bot.definition.SimpleStoryStep -import ai.tock.bot.definition.StoryHandlerDefinition import ai.tock.bot.definition.StoryStep +import ai.tock.bot.definition.StoryStepDef import ai.tock.bot.engine.BotBus import ai.tock.translator.I18nLabelValue @@ -96,7 +96,7 @@ data class StoryDefinitionConfigurationStep( override fun hashCode(): Int = name.hashCode() - override val children: Set> + override val children: Set get() = configuration.children.map { it.toStoryStep(storyConfiguration) }.toSet() override val hasNoChildren: Boolean get() = children.isEmpty() @@ -109,7 +109,7 @@ data class StoryDefinitionConfigurationStep( val hasNoChildren: Boolean get() = children.isEmpty() - constructor(step: StoryStep<*>) : + constructor(step: StoryStepDef) : this( step.name, step.intent?.intentWithoutNamespace(), @@ -118,7 +118,7 @@ data class StoryDefinitionConfigurationStep( builtin ) - fun toStoryStep(story: StoryDefinitionConfiguration): StoryStep = Step(this, story) + fun toStoryStep(story: StoryDefinitionConfiguration): SimpleStoryStep = Step(this, story) override fun findNextSteps(bus: BotBus, story: StoryDefinitionConfiguration): List = children.map { it.userSentenceLabel ?: it.userSentence } diff --git a/bot/engine/src/main/kotlin/connector/Connector.kt b/bot/engine/src/main/kotlin/connector/Connector.kt index 42ac8c234a..8ba763f323 100644 --- a/bot/engine/src/main/kotlin/connector/Connector.kt +++ b/bot/engine/src/main/kotlin/connector/Connector.kt @@ -18,8 +18,7 @@ package ai.tock.bot.connector import ai.tock.bot.connector.media.MediaMessage import ai.tock.bot.definition.IntentAware -import ai.tock.bot.definition.StoryHandlerDefinition -import ai.tock.bot.definition.StoryStep +import ai.tock.bot.definition.StoryStepDef import ai.tock.bot.engine.BotBus import ai.tock.bot.engine.ConnectorController import ai.tock.bot.engine.action.ActionNotificationType @@ -111,7 +110,7 @@ interface Connector { controller: ConnectorController, recipientId: PlayerId, intent: IntentAware, - step: StoryStep? = null, + step: StoryStepDef? = null, parameters: Map = emptyMap(), notificationType: ActionNotificationType?, errorListener: (Throwable) -> Unit = {} diff --git a/bot/engine/src/main/kotlin/connector/ConnectorHandler.kt b/bot/engine/src/main/kotlin/connector/ConnectorHandler.kt index 85f33e8c4d..51ee5e7a0b 100644 --- a/bot/engine/src/main/kotlin/connector/ConnectorHandler.kt +++ b/bot/engine/src/main/kotlin/connector/ConnectorHandler.kt @@ -16,12 +16,17 @@ package ai.tock.bot.connector +import ai.tock.bot.definition.ConnectorSpecificHandling import kotlin.annotation.AnnotationTarget.ANNOTATION_CLASS /** - * Annotation used to annotate [StoryHandlerDefinitionBase] implementation, - * in order to provide [ConnectorStoryHandler] for each connector. - * Used only by connector implementation. + * `@ConnectorHandler` is a meta-annotation for annotation classes that are used + * to provide a [ConnectorSpecificHandling] for a specific connector type. + * + * The annotated class must have a single property + * with the name `value` and the type `KClass`. + * + * This annotation is only supposed to be used by connector implementations, not by regular user code. */ @Target(ANNOTATION_CLASS) @MustBeDocumented diff --git a/bot/engine/src/main/kotlin/connector/ConnectorIdHandler.kt b/bot/engine/src/main/kotlin/connector/ConnectorIdHandler.kt index dd1c47c3e2..aae3765366 100644 --- a/bot/engine/src/main/kotlin/connector/ConnectorIdHandler.kt +++ b/bot/engine/src/main/kotlin/connector/ConnectorIdHandler.kt @@ -16,6 +16,7 @@ package ai.tock.bot.connector +import ai.tock.bot.definition.ConnectorSpecificHandling import ai.tock.bot.definition.ConnectorStoryHandler import kotlin.reflect.KClass @@ -24,4 +25,4 @@ import kotlin.reflect.KClass * in order to provide [ConnectorStoryHandler] for each connector id. */ @MustBeDocumented -annotation class ConnectorIdHandler(val connectorId: String, val value: KClass>) +annotation class ConnectorIdHandler(val connectorId: String, val value: KClass) diff --git a/bot/engine/src/main/kotlin/definition/AsyncConfigurableStoryHandler.kt b/bot/engine/src/main/kotlin/definition/AsyncConfigurableStoryHandler.kt new file mode 100644 index 0000000000..4e1c52516d --- /dev/null +++ b/bot/engine/src/main/kotlin/definition/AsyncConfigurableStoryHandler.kt @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2017/2025 SNCF Connect & Tech + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ai.tock.bot.definition + +import ai.tock.bot.engine.AsyncBus +import ai.tock.bot.engine.BotBus +import ai.tock.shared.coroutines.ExperimentalTockCoroutines + +@ExperimentalTockCoroutines +class AsyncConfigurableStoryHandler( + /** + * The main intent of the story definition. + */ + mainIntent: Intent? = null, + /** + * The [AsyncStoryHandling] creator. Defines [AsyncDelegatingStoryHandlerBase.newHandlerDefinition]. + */ + private val handlerDefCreator: AsyncStoryHandlingCreator, + /** + * Check preconditions. if [BotBus.end] is called in this function, + * [StoryHandlerDefinition.handle] is not called and the handling of bot answer is over. + */ + private val preconditionsChecker: suspend AsyncBus.() -> D, +): AsyncDelegatingStoryHandlerBase(mainIntent) { + override fun checkPreconditions(): suspend AsyncBus.() -> D = preconditionsChecker + + override fun newHandlerDefinition(bus: AsyncBus, preconditionResult: D): T = handlerDefCreator.create(bus, preconditionResult) +} diff --git a/bot/engine/src/main/kotlin/definition/AsyncConnectorHandling.kt b/bot/engine/src/main/kotlin/definition/AsyncConnectorHandling.kt new file mode 100644 index 0000000000..fce88c92f0 --- /dev/null +++ b/bot/engine/src/main/kotlin/definition/AsyncConnectorHandling.kt @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2017/2025 SNCF Connect & Tech + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ai.tock.bot.definition + +import ai.tock.shared.coroutines.ExperimentalTockCoroutines + +/** + * Story handling for a specific connector. + * + * Implementations should usually use [ConnectorStoryHandlerBase]. + */ +@ExperimentalTockCoroutines +interface AsyncConnectorHandling : ConnectorSpecificHandling { + + /** + * The [AsyncStoryHandling] of this connector handler. + */ + val context: T +} diff --git a/bot/engine/src/main/kotlin/definition/AsyncConnectorHandlingBase.kt b/bot/engine/src/main/kotlin/definition/AsyncConnectorHandlingBase.kt new file mode 100644 index 0000000000..9b26c2d637 --- /dev/null +++ b/bot/engine/src/main/kotlin/definition/AsyncConnectorHandlingBase.kt @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2017/2025 SNCF Connect & Tech + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ai.tock.bot.definition + +import ai.tock.bot.engine.AsyncBus +import ai.tock.shared.coroutines.ExperimentalTockCoroutines + +/** + * Story handling for a specific connector. + */ +@ExperimentalTockCoroutines +abstract class AsyncConnectorHandlingBase>>( + override val context: T +): AsyncConnectorHandling, AsyncBus by context diff --git a/bot/engine/src/main/kotlin/definition/AsyncDefinitionBuilders.kt b/bot/engine/src/main/kotlin/definition/AsyncDefinitionBuilders.kt new file mode 100644 index 0000000000..c43bb91908 --- /dev/null +++ b/bot/engine/src/main/kotlin/definition/AsyncDefinitionBuilders.kt @@ -0,0 +1,206 @@ +/* + * Copyright (C) 2017/2025 SNCF Connect & Tech + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ai.tock.bot.definition + +import ai.tock.bot.engine.AsyncBus +import ai.tock.shared.coroutines.ExperimentalTockCoroutines +import ai.tock.translator.UserInterfaceType + + +/** + * Creates a new coroutine-based story. + */ +@JvmName("asyncStoryDataDefWithSteps") +@ExperimentalTockCoroutines +inline fun storyDef( + /** + * The [StoryDefinition.mainIntent] name. + */ + intentName: String, + /** + * The optionals other [StoryDefinition.starterIntents]. + */ + otherStarterIntents: Set = emptySet(), + /** + * The others [StoryDefinition.intents] - ie the "secondary" intents. + */ + secondaryIntents: Set = emptySet(), + /** + * The [StoryStep] of the story if any. + */ + steps: List> = emptyList(), + /** + * Steps that do not use data from the main story execution + */ + simpleSteps: List> = emptyList(), + /** + * Is this story unsupported for a [UserInterfaceType]? + */ + unsupportedUserInterface: UserInterfaceType? = null, + /** + * The [HandlerDef] creator. Defines [StoryHandlerBase.newHandlerDefinition]. + */ + handling: AsyncStoryHandlingCreator, + /** + * Check preconditions. if [AsyncBus.end] is called in this function, + * [StoryHandlerDefinition.handle] is not called and the handling of bot answer is over. + */ + noinline preconditionsChecker: suspend AsyncBus.() -> D +): AsyncStoryDefinitionBase> = + AsyncStoryDefinitionBase( + intentName, + AsyncConfigurableStoryHandler(Intent(intentName), handling, preconditionsChecker), + otherStarterIntents, + secondaryIntents, + steps + simpleSteps.onEach { check(it !is AsyncStoryDataStep<*, *, *>) { + "Story data steps must be provided in the steps parameter, not simpleSteps" + } }, + unsupportedUserInterface + ) + +/** + * Creates a new coroutine-based story. + */ +@ExperimentalTockCoroutines +@JvmName("asyncStoryDefWithSteps") +inline fun storyDef( + /** + * The [StoryDefinition.mainIntent] name. + */ + intentName: String, + /** + * The optionals other [StoryDefinition.starterIntents]. + */ + otherStarterIntents: Set = emptySet(), + /** + * The others [StoryDefinition.intents] - ie the "secondary" intents. + */ + secondaryIntents: Set = emptySet(), + /** + * The [StoryStep] of the story if any. + */ + steps: List> = emptyList(), + /** + * Is this story unsupported for a [UserInterfaceType]? + */ + unsupportedUserInterface: UserInterfaceType? = null, + /** + * The [HandlerDef] creator. Defines [StoryHandlerBase.newHandlerDefinition]. + */ + handling: SimpleAsyncStoryHandlingCreator, + /** + * Check preconditions. if [AsyncBus.end] is called in this function, + * [StoryHandlerDefinition.handle] is not called and the handling of bot answer is over. + */ + noinline preconditionsChecker: suspend AsyncBus.() -> Unit = {} +): AsyncStoryDefinitionBase> = + AsyncStoryDefinitionBase( + intentName, + AsyncConfigurableStoryHandler(Intent(intentName), handling, preconditionsChecker), + otherStarterIntents, + secondaryIntents, + steps, + unsupportedUserInterface + ) + +/** + * Creates a new coroutine-based story with steps defined in an enum class. + */ +@ExperimentalTockCoroutines +@JvmName("asyncStoryDataDefWithSteps") +inline fun storyDefWithSteps( + /** + * The [StoryDefinition.mainIntent] name. + */ + intentName: String, + /** + * The optionals other [StoryDefinition.starterIntents]. + */ + otherStarterIntents: Set = emptySet(), + /** + * The others [StoryDefinition.intents] - ie the "secondary" intents. + */ + secondaryIntents: Set = emptySet(), + /** + * Is this story unsupported for a [UserInterfaceType]? + */ + unsupportedUserInterface: UserInterfaceType? = null, + /** + * The [HandlerDef] creator. Defines [StoryHandlerBase.newHandlerDefinition]. + */ + handling: AsyncStoryHandlingCreator, + /** + * Check preconditions. if [AsyncBus.end] is called in this function, + * [StoryHandlerDefinition.handle] is not called and the handling of bot answer is over. + */ + noinline preconditionsChecker: suspend AsyncBus.() -> D +): AsyncStoryDefinitionBase where S : Enum, S : AsyncStoryStep = + AsyncStoryDefinitionBase( + name = intentName, + storyHandler = AsyncConfigurableStoryHandler( + Intent(intentName), handling, + preconditionsChecker + ), + otherStarterIntents = otherStarterIntents, + secondaryIntents = secondaryIntents, + stepsList = enumValues().toList(), + unsupportedUserInterface = unsupportedUserInterface + ) + +/** + * Creates a new coroutine-based story with steps defined in an enum class. + */ +@ExperimentalTockCoroutines +@JvmName("asyncStoryDefWithSteps") +inline fun storyDefWithSteps( + /** + * The [StoryDefinition.mainIntent] name. + */ + intentName: String, + /** + * The optionals other [StoryDefinition.starterIntents]. + */ + otherStarterIntents: Set = emptySet(), + /** + * The others [StoryDefinition.intents] - ie the "secondary" intents. + */ + secondaryIntents: Set = emptySet(), + /** + * Is this story unsupported for a [UserInterfaceType]? + */ + unsupportedUserInterface: UserInterfaceType? = null, + /** + * The [HandlerDef] creator. Defines [StoryHandlerBase.newHandlerDefinition]. + */ + handling: SimpleAsyncStoryHandlingCreator, + /** + * Check preconditions. if [AsyncBus.end] is called in this function, + * [StoryHandlerDefinition.handle] is not called and the handling of bot answer is over. + */ + noinline preconditionsChecker: suspend AsyncBus.() -> Unit = {} +): AsyncStoryDefinitionBase where S : Enum, S : AsyncStoryStep = + AsyncStoryDefinitionBase( + name = intentName, + storyHandler = AsyncConfigurableStoryHandler( + Intent(intentName), handling, + preconditionsChecker + ), + otherStarterIntents = otherStarterIntents, + secondaryIntents = secondaryIntents, + stepsList = enumValues().toList(), + unsupportedUserInterface = unsupportedUserInterface + ) diff --git a/bot/engine/src/main/kotlin/definition/AsyncDelegatingStoryHandlerBase.kt b/bot/engine/src/main/kotlin/definition/AsyncDelegatingStoryHandlerBase.kt new file mode 100644 index 0000000000..ef23344f41 --- /dev/null +++ b/bot/engine/src/main/kotlin/definition/AsyncDelegatingStoryHandlerBase.kt @@ -0,0 +1,125 @@ +/* + * Copyright (C) 2017/2025 SNCF Connect & Tech + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ai.tock.bot.definition + +import ai.tock.bot.engine.AsyncBus +import ai.tock.shared.coroutines.ExperimentalTockCoroutines + +/** + * An [AsyncStoryHandler] that checks preconditions and dispatches calls to [AsyncStoryStep] and [AsyncStoryHandling] + */ +@ExperimentalTockCoroutines +abstract class AsyncDelegatingStoryHandlerBase( + mainIntent: Intent?, +) : AsyncStoryHandlerBase(mainIntent) { + + /** + * Checks preconditions - if [AsyncBus.end] is called, + * [StoryHandlerDefinition.handle] is not called and the handling of bot answer is over. + */ + abstract fun checkPreconditions(): suspend AsyncBus.() -> D + + /** + * Selects step from [HandlerDef], optional data and [StoryDefinition]. + */ + @Suppress("UNCHECKED_CAST") + open fun selectStepFromContext( + bus: AsyncBus, + context: T, + preconditionResult: D, + storyDefinition: StoryDefinition?, + ): AsyncStoryStep? { + storyDefinition?.steps?.also { steps -> + for (s in steps) { + if (shouldSelectStep(s as AsyncStoryStep, context, preconditionResult)) { + bus.step = s + break + } + } + } + return bus.step as AsyncStoryStep? + } + + protected open fun shouldSelectStep( + s: AsyncStoryStep, + context: T, + preconditionResult: D, + ): Boolean { + return with (context) { + if (s is AsyncStoryDataStep<*, *, *>) { + @Suppress("UNCHECKED_CAST") + with(s as AsyncStoryDataStep) { + selectFromContextAndData(preconditionResult) + } + } else { + with(s) { + selectFromContext() + } + } + } + } + + override suspend fun action(bus: AsyncBus) { + val preconditionResult = checkPreconditions().invoke(bus) + if (!bus.isEndCalled()) { + val storyDefinition = findStoryDefinition(bus) + val handler: T = newHandlerDefinition(bus, preconditionResult) + + // final round of step selection (after Story.computeCurrentStep) + val step = selectStepFromContext(bus, handler, preconditionResult, storyDefinition) + + if (step != null) { + handler.handleStep(bus, step, preconditionResult) + } + + if (!bus.isEndCalled()) { + handler.handle() + } + } + } + + abstract fun newHandlerDefinition(bus: AsyncBus, preconditionResult: D): T + + protected open suspend fun T.handleStep(bus: AsyncBus, step: AsyncStoryStep, preconditionResult: D) { + if (step is AsyncStoryDataStep<*, *, *>) { + @Suppress("UNCHECKED_CAST") + handleDataStep( + step as AsyncStoryDataStep, + preconditionResult, + bus + ) + } else { + with (step) { + answer() + } + } + } + + private suspend fun T.handleDataStep( + step: AsyncStoryDataStep, + preconditionResult: D, + bus: AsyncBus + ) { + with (step) { + val data = checkPreconditions(preconditionResult) + + if (!bus.isEndCalled()) { + answer(data) + } + } + } +} diff --git a/bot/engine/src/main/kotlin/definition/AsyncStoryDataStep.kt b/bot/engine/src/main/kotlin/definition/AsyncStoryDataStep.kt new file mode 100644 index 0000000000..b1813a76e6 --- /dev/null +++ b/bot/engine/src/main/kotlin/definition/AsyncStoryDataStep.kt @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2017/2025 SNCF Connect & Tech + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ai.tock.bot.definition + +import ai.tock.bot.engine.AsyncBus +import ai.tock.bot.engine.BotBus +import ai.tock.shared.coroutines.ExperimentalTockCoroutines + +@ExperimentalTockCoroutines +interface AsyncStoryDataStep : AsyncStoryStep { + override suspend fun T.answer() { + answer(checkPreconditions(null)) + } + + /** + * Does this Step have to be selected for the current context and data? + * + * This method is called if [AsyncDelegatingStoryHandlerBase.checkPreconditions] does not call [AsyncBus.end]. + * If this method returns true, the step is selected and remaining steps are not tested. + * This method is called even if [selectFromAction] previously returned `false`. + * + * Returning `true` causes the step to be selected even if another step got previously selected. + * Returning `false` does not deselect the step if it was already selected. + * + * @see selectFromAction + */ + fun T.selectFromContextAndData(mainData: TD?): Boolean = false + + /** + * Checks preconditions - if [BotBus.end] is called, + * [StoryHandlerDefinition.handle] is not called and the handling of bot answer is over. + * Returned data is used in subsequent call of [answer] + */ + suspend fun T.checkPreconditions(mainData: TD?): D + + suspend fun T.answer(data: D) +} diff --git a/bot/engine/src/main/kotlin/definition/AsyncStoryDefinition.kt b/bot/engine/src/main/kotlin/definition/AsyncStoryDefinition.kt new file mode 100644 index 0000000000..13c183189b --- /dev/null +++ b/bot/engine/src/main/kotlin/definition/AsyncStoryDefinition.kt @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2017/2025 SNCF Connect & Tech + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ai.tock.bot.definition + +import ai.tock.shared.coroutines.ExperimentalTockCoroutines + +/** + * Story definitions should usually not directly extend this class, + * but instead extend [AsyncStoryDefinitionBase]. + */ +@ExperimentalTockCoroutines +interface AsyncStoryDefinition : StoryDefinition { + override val steps: Set> + override val storyHandler: AsyncStoryHandler +} diff --git a/bot/engine/src/main/kotlin/definition/AsyncStoryDefinitionBase.kt b/bot/engine/src/main/kotlin/definition/AsyncStoryDefinitionBase.kt new file mode 100644 index 0000000000..6401350ab3 --- /dev/null +++ b/bot/engine/src/main/kotlin/definition/AsyncStoryDefinitionBase.kt @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2017/2025 SNCF Connect & Tech + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ai.tock.bot.definition + +import ai.tock.shared.coroutines.ExperimentalTockCoroutines +import ai.tock.translator.UserInterfaceType + +/** + * Default [AsyncStoryDefinition] implementation. + */ +@OptIn(ExperimentalTockCoroutines::class) +open class AsyncStoryDefinitionBase>( + val name: String, + override val storyHandler: AsyncStoryHandlerBase, + otherStarterIntents: Set = emptySet(), + secondaryIntents: Set = emptySet(), + stepsList: List = emptyList(), + unsupportedUserInterface: UserInterfaceType? = null, + override val tags: Set = emptySet(), +) : AsyncStoryDefinition, StoryDefinitionWithSteps { + + override val steps: Set = + stepsList.onEach { + if (it.intent == null) { + stepToIntentRepository[it] = this@AsyncStoryDefinitionBase + } + }.toSet() + + override val unsupportedUserInterfaces: Set = listOfNotNull(unsupportedUserInterface).toSet() + + override val id: String get() = name + override val starterIntents: Set = + setOf(Intent(name)) + otherStarterIntents.map { it.wrappedIntent() }.toSet() + override val intents: Set = + setOf(Intent(name)) + (otherStarterIntents + secondaryIntents).map { it.wrappedIntent() }.toSet() + + override fun toString(): String = "Story[$name]" +} diff --git a/bot/engine/src/main/kotlin/definition/AsyncStoryHandler.kt b/bot/engine/src/main/kotlin/definition/AsyncStoryHandler.kt new file mode 100644 index 0000000000..77740737bd --- /dev/null +++ b/bot/engine/src/main/kotlin/definition/AsyncStoryHandler.kt @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2017/2025 SNCF Connect & Tech + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ai.tock.bot.definition + +import ai.tock.bot.engine.AsyncBotBus +import ai.tock.bot.engine.AsyncBus +import ai.tock.bot.engine.BotBus +import ai.tock.shared.coroutines.ExperimentalTockCoroutines +import kotlinx.coroutines.runBlocking + +/** + * Receive a sentence or action, and send the answer asynchronously. + * + * Story handlers should usually not directly extend this class, but instead extend [AsyncStoryHandlerBase]. + */ +@ExperimentalTockCoroutines +interface AsyncStoryHandler : StoryHandler { + + @Deprecated("Use coroutines to call this interface", replaceWith = ReplaceWith("handle(asyncBus)")) + override fun handle(bus: BotBus) { + // This should only happen in automated tests + runBlocking { handle(AsyncBotBus(bus)) } + } + + /** + * Receive a message from the bus. + * + * @param bus the bus used to get the message and send the answer + */ + suspend fun handle(bus: AsyncBus) +} diff --git a/bot/engine/src/main/kotlin/definition/AsyncStoryHandlerBase.kt b/bot/engine/src/main/kotlin/definition/AsyncStoryHandlerBase.kt new file mode 100644 index 0000000000..88a40ee5d9 --- /dev/null +++ b/bot/engine/src/main/kotlin/definition/AsyncStoryHandlerBase.kt @@ -0,0 +1,115 @@ +/* + * Copyright (C) 2017/2025 SNCF Connect & Tech + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ai.tock.bot.definition + +import ai.tock.bot.engine.AsyncBotBus +import ai.tock.bot.engine.AsyncBus +import ai.tock.shared.InternalTockApi +import ai.tock.shared.coroutines.ExperimentalTockCoroutines +import ai.tock.shared.defaultNamespace +import ai.tock.translator.I18nKeyProvider.Companion.generateKey +import ai.tock.translator.I18nLabelValue +import ai.tock.translator.I18nLocalizedLabel +import mu.KotlinLogging + +/** + * An [AsyncStoryHandler] with a base `handleAsync` implementation and i18n utilities + */ +@ExperimentalTockCoroutines +abstract class AsyncStoryHandlerBase( + private val mainIntent: Intent?, +) : AsyncStoryHandler, I18nStoryHandler, IntentAware { + companion object { + private val logger = KotlinLogging.logger {} + } + + @Volatile + override var i18nNamespace: String = defaultNamespace + @InternalTockApi set + + override suspend fun handle(bus: AsyncBus) { + val baseBus = (bus as AsyncBotBus).botBus + val storyDefinition = findStoryDefinition(bus) + // if not supported user interface, use unknown + if (storyDefinition?.unsupportedUserInterfaces?.contains(bus.userInterfaceType) == true) { + baseBus.botDefinition.unknownStory.storyHandler.handle(baseBus) + } else { + // set current i18n provider + baseBus.i18nProvider = this + + action(bus) + + if (!bus.isEndCalled() && !baseBus.connectorData.skipAnswer) { + logger.warn { + "Bus.end not called for story ${baseBus.story.definition.id}, user ${bus.userId.id} and connector ${baseBus.targetConnectorType}" + } + } + } + } + + protected abstract suspend fun action(bus: AsyncBus) + + protected fun AsyncBus.isEndCalled() = StoryHandlerBase.isEndCalled((this as AsyncBotBus).botBus) + + /** + * Finds the story definition of this handler. + */ + open fun findStoryDefinition(bus: AsyncBus): StoryDefinition? = + (bus as AsyncBotBus).botBus.botDefinition.findStoryByStoryHandler(this, bus.connectorId) + + /** + * Story i18n category. + */ + protected open fun i18nKeyCategory(): String = mainIntent?.name ?: i18nNamespace + + override fun i18n(defaultLabel: CharSequence, args: List): I18nLabelValue { + val category = i18nKeyCategory() + return I18nLabelValue( + generateKey(i18nNamespace, category, defaultLabel), + i18nNamespace, + category, + defaultLabel, + args + ) + } + + /** + * Gets an i18n label with the specified key. Current namespace is used for the categorization. + */ + override fun i18nKey(key: String, defaultLabel: CharSequence, vararg args: Any?): I18nLabelValue { + return i18nKey(key, defaultLabel, emptySet(), *args) + } + + /** + * Gets an i18n label with the specified key and defaults. Current namespace is used for the categorization. + */ + override fun i18nKey(key: String, defaultLabel: CharSequence, defaultI18n: Set, vararg args: Any?): I18nLabelValue { + val category = i18nKeyCategory() + return I18nLabelValue( + key, + i18nNamespace, + category, + defaultLabel, + args.toList(), + defaultI18n, + ) + } + + override fun wrappedIntent(): Intent { + return mainIntent ?: error("unknown main intent name") + } +} diff --git a/bot/engine/src/main/kotlin/definition/AsyncStoryHandling.kt b/bot/engine/src/main/kotlin/definition/AsyncStoryHandling.kt new file mode 100644 index 0000000000..b7792d2f0e --- /dev/null +++ b/bot/engine/src/main/kotlin/definition/AsyncStoryHandling.kt @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2017/2025 SNCF Connect & Tech + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ai.tock.bot.definition + +import ai.tock.shared.coroutines.ExperimentalTockCoroutines + +@ExperimentalTockCoroutines +interface AsyncStoryHandling { + /** + * The main method to implement. + */ + suspend fun handle() +} diff --git a/bot/engine/src/main/kotlin/definition/AsyncStoryHandlingBase.kt b/bot/engine/src/main/kotlin/definition/AsyncStoryHandlingBase.kt new file mode 100644 index 0000000000..aa4a2b357b --- /dev/null +++ b/bot/engine/src/main/kotlin/definition/AsyncStoryHandlingBase.kt @@ -0,0 +1,93 @@ +/* + * Copyright (C) 2017/2025 SNCF Connect & Tech + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ai.tock.bot.definition + +import ai.tock.bot.connector.ConnectorHandler +import ai.tock.bot.connector.ConnectorIdHandlers +import ai.tock.bot.connector.ConnectorType +import ai.tock.bot.engine.AsyncBus +import ai.tock.shared.coroutines.ExperimentalTockCoroutines +import ai.tock.shared.injector +import ai.tock.shared.provide +import com.github.salomonbrys.kodein.KodeinInjector +import kotlin.LazyThreadSafetyMode.PUBLICATION +import mu.KotlinLogging + +@ExperimentalTockCoroutines +abstract class AsyncStoryHandlingBase>>(val bus: AsyncBus) : AsyncStoryHandling, AsyncBus by bus { + + companion object { + + private val logger = KotlinLogging.logger {} + + private val connectorProvider: ConnectorHandlerProvider + get() = + try { + injector.provide() + } catch (_: KodeinInjector.UninjectedException) { + DefaultConnectorHandlerProvider + } + } + + /** + * The method to implement if there is no [StoryStep] in the [StoryDefinition] + * or when current [StoryStep] is null + */ + protected abstract suspend fun answer() + + /** + * Default implementation redirect to answer. + */ + override suspend fun handle() { + answer() + } + + /** + * Shortcut for [AsyncBus.targetConnectorType]. + */ + val connectorType: ConnectorType get() = bus.targetConnectorType + + /** + * Method to override in order to provide [ConnectorStoryHandler]. + * Default implementation use annotations annotated with @[ConnectorHandler]. + */ + @Suppress("UNCHECKED_CAST") + protected open fun findConnector(connectorType: ConnectorType): T? = + connectorProvider.provide(this, connectorType) as? T? + + /** + * Method to override in order to provide [ConnectorStoryHandler]. + * Default implementation use annotations annotated with @[ConnectorIdHandlers]. + */ + @Suppress("UNCHECKED_CAST") + protected open fun findConnector(connectorId: String): T? = + connectorProvider.provide(this, connectorId) as? T? + + /** + * Provides the current [ConnectorStoryHandler] using [findConnector]. + */ + val connector: T? by lazy(PUBLICATION) { + (findConnector(connectorId) ?: findConnector(connectorType)) + .also { if (it == null) logger.warn { "unsupported connector type $connectorType/$connectorId for ${this::class}" } } + } + + /** + * Provides a not null [connector]. Throws NPE if [connector] is null. + */ + val c: T + get() = connector!! +} diff --git a/bot/engine/src/main/kotlin/definition/AsyncStoryHandlingCreator.kt b/bot/engine/src/main/kotlin/definition/AsyncStoryHandlingCreator.kt new file mode 100644 index 0000000000..5cb4605016 --- /dev/null +++ b/bot/engine/src/main/kotlin/definition/AsyncStoryHandlingCreator.kt @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2017/2025 SNCF Connect & Tech + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ai.tock.bot.definition + +import ai.tock.bot.engine.AsyncBus +import ai.tock.shared.coroutines.ExperimentalTockCoroutines + +@ExperimentalTockCoroutines +fun interface AsyncStoryHandlingCreator { + fun create(bus: AsyncBus, data: D): T +} + +@ExperimentalTockCoroutines +fun interface SimpleAsyncStoryHandlingCreator : AsyncStoryHandlingCreator { + override fun create(bus: AsyncBus, data: Unit): T = create(bus) + fun create(bus: AsyncBus): T +} diff --git a/bot/engine/src/main/kotlin/definition/AsyncStoryStep.kt b/bot/engine/src/main/kotlin/definition/AsyncStoryStep.kt new file mode 100644 index 0000000000..cbe89feb43 --- /dev/null +++ b/bot/engine/src/main/kotlin/definition/AsyncStoryStep.kt @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2017/2025 SNCF Connect & Tech + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ai.tock.bot.definition + +import ai.tock.bot.engine.AsyncBus +import ai.tock.shared.coroutines.ExperimentalTockCoroutines + +@ExperimentalTockCoroutines +interface AsyncStoryStep : StoryStepDef { + override val name: String + + /** + * Does this Step have to be selected for the current context? + * + * This method is called if [AsyncDelegatingStoryHandlerBase.checkPreconditions] does not call [AsyncBus.end]. + * If this method returns true, the step is selected and remaining steps are not tested. + * This method is called even if [selectFromAction] previously returned `false`. + * + * Returning `true` causes the step to be selected even if another step got previously selected. + * Returning `false` does not deselect the step if it was already selected. + * + * Prefer implementing [selectFromAction] to avoid overriding default step selection mechanisms. + * + * @see selectFromAction + */ + fun T.selectFromContext(): Boolean = false + + suspend fun T.answer() {} + + override val children: Set> get() = emptySet() +} diff --git a/bot/engine/src/main/kotlin/definition/ConnectorHandlerProvider.kt b/bot/engine/src/main/kotlin/definition/ConnectorHandlerProvider.kt index 4316a85dd6..a1cdf61220 100644 --- a/bot/engine/src/main/kotlin/definition/ConnectorHandlerProvider.kt +++ b/bot/engine/src/main/kotlin/definition/ConnectorHandlerProvider.kt @@ -19,6 +19,7 @@ package ai.tock.bot.definition import ai.tock.bot.connector.ConnectorHandler import ai.tock.bot.connector.ConnectorIdHandlers import ai.tock.bot.connector.ConnectorType +import ai.tock.shared.coroutines.ExperimentalTockCoroutines /** * Provides [ConnectorHandler]. @@ -36,4 +37,18 @@ interface ConnectorHandlerProvider { * Default implementation use annotations annotated with @[ConnectorIdHandlers]. */ fun provide(storyDef: StoryHandlerDefinition, connectorId: String): ConnectorStoryHandlerBase<*>? = null + + /** + * Method to override in order to provide [ConnectorStoryHandler] from [ConnectorType]. + * Default implementation use annotations annotated with @[ConnectorHandler]. + */ + @ExperimentalTockCoroutines + fun provide(storyDef: AsyncStoryHandling, connectorType: ConnectorType): AsyncConnectorHandlingBase<*>? = null + + /** + * Method to override in order to provide [ConnectorStoryHandler] from connectorId. + * Default implementation use annotations annotated with @[ConnectorIdHandlers]. + */ + @ExperimentalTockCoroutines + fun provide(storyDef: AsyncStoryHandling, connectorId: String): AsyncConnectorHandlingBase<*>? = null } diff --git a/bot/engine/src/main/kotlin/definition/ConnectorSpecificHandling.kt b/bot/engine/src/main/kotlin/definition/ConnectorSpecificHandling.kt new file mode 100644 index 0000000000..ffa54874e1 --- /dev/null +++ b/bot/engine/src/main/kotlin/definition/ConnectorSpecificHandling.kt @@ -0,0 +1,23 @@ +/* + * Copyright (C) 2017/2025 SNCF Connect & Tech + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ai.tock.bot.definition + +/** + * @see AsyncConnectorHandling + * @see ConnectorStoryHandler + */ +interface ConnectorSpecificHandling diff --git a/bot/engine/src/main/kotlin/definition/ConnectorStoryHandler.kt b/bot/engine/src/main/kotlin/definition/ConnectorStoryHandler.kt index a6bfff213d..f3ef097dcf 100644 --- a/bot/engine/src/main/kotlin/definition/ConnectorStoryHandler.kt +++ b/bot/engine/src/main/kotlin/definition/ConnectorStoryHandler.kt @@ -24,7 +24,7 @@ import ai.tock.bot.engine.BotBus * * Implementations should usually use [ConnectorStoryHandlerBase]. */ -interface ConnectorStoryHandler : BotBus { +interface ConnectorStoryHandler : ConnectorSpecificHandling, BotBus { /** * The [StoryHandlerDefinition] of this connector handler. diff --git a/bot/engine/src/main/kotlin/definition/DefaultConnectorHandlerProvider.kt b/bot/engine/src/main/kotlin/definition/DefaultConnectorHandlerProvider.kt index 55c044c0b3..8b05c5dc75 100644 --- a/bot/engine/src/main/kotlin/definition/DefaultConnectorHandlerProvider.kt +++ b/bot/engine/src/main/kotlin/definition/DefaultConnectorHandlerProvider.kt @@ -20,14 +20,15 @@ import ai.tock.bot.connector.ConnectorHandler import ai.tock.bot.connector.ConnectorIdHandlers import ai.tock.bot.connector.ConnectorType import ai.tock.bot.engine.BotBus +import ai.tock.shared.coroutines.ExperimentalTockCoroutines import ai.tock.shared.mapNotNullValues import java.util.concurrent.ConcurrentHashMap import kotlin.reflect.KClass import kotlin.reflect.full.findAnnotation import kotlin.reflect.full.isSubtypeOf import kotlin.reflect.full.primaryConstructor -import kotlin.reflect.full.starProjectedType import kotlin.reflect.full.superclasses +import kotlin.reflect.typeOf internal object DefaultConnectorHandlerProvider : ConnectorHandlerProvider { @@ -35,10 +36,9 @@ internal object DefaultConnectorHandlerProvider : ConnectorHandlerProvider { private val connectorIdHandlerMap: MutableMap, Map>> = ConcurrentHashMap() - private fun getConnectorHandlerMap(storyDef: StoryHandlerDefinition): Map> { - val kclass = storyDef.javaClass.kotlin - return connectorHandlerMap.getOrPut(kclass) { - getAllAnnotations(kclass) + private fun getConnectorHandlerMap(contextClass: KClass<*>): Map> { + return connectorHandlerMap.getOrPut(contextClass) { + getAllAnnotations(contextClass) .filter { it.annotationClass.findAnnotation() != null } .mapNotNullValues { a: Annotation -> a.annotationClass.findAnnotation()!!.connectorTypeId to ( @@ -51,12 +51,11 @@ internal object DefaultConnectorHandlerProvider : ConnectorHandlerProvider { } } - private fun getConnectorIdHandlerMap(storyDef: StoryHandlerDefinition): Map> { - val kclass = storyDef.javaClass.kotlin - return connectorIdHandlerMap.getOrPut(kclass) { - kclass.findAnnotation()?.handlers?.map { connectorIdHandler -> + private fun getConnectorIdHandlerMap(contextClass: KClass<*>): Map> { + return connectorIdHandlerMap.getOrPut(contextClass) { + contextClass.findAnnotation()?.handlers?.associate { connectorIdHandler -> connectorIdHandler.connectorId to connectorIdHandler.value - }?.toMap() ?: mapOf() + } ?: mapOf() } } @@ -77,24 +76,42 @@ internal object DefaultConnectorHandlerProvider : ConnectorHandlerProvider { } @Suppress("UNCHECKED_CAST") - private fun provideConnectorStoryHandler(storyDef: StoryHandlerDefinition, connectorDefClass: KClass<*>?): ConnectorStoryHandlerBase<*>? { + private inline fun provideConnectorStoryHandler(storyDef: T, connectorDefClass: KClass<*>?): C? { val p = connectorDefClass?.primaryConstructor return p?.callBy( mapOf( - p.parameters.first { - it.type.isSubtypeOf(BotBus::class.starProjectedType) - } to storyDef + (p.parameters.firstOrNull { + it.type.isSubtypeOf(typeOf()) + } ?: throw NoSuchElementException("Primary constructor of $connectorDefClass is missing its context parameter")) to storyDef ) - ) as ConnectorStoryHandlerBase<*>? + ) as C? } override fun provide(storyDef: StoryHandlerDefinition, connectorType: ConnectorType): ConnectorStoryHandlerBase<*>? { - val connectorDef = getConnectorHandlerMap(storyDef)[connectorType.id] - return provideConnectorStoryHandler(storyDef, connectorDef) + val connectorDef = getConnectorHandlerMap(storyDef.javaClass.kotlin)[connectorType.id] + return provideConnectorStoryHandler, BotBus>(storyDef, connectorDef) } override fun provide(storyDef: StoryHandlerDefinition, connectorId: String): ConnectorStoryHandlerBase<*>? { - val connectorDef = getConnectorIdHandlerMap(storyDef)[connectorId] - return provideConnectorStoryHandler(storyDef, connectorDef) + val connectorDef = getConnectorIdHandlerMap(storyDef.javaClass.kotlin)[connectorId] + return provideConnectorStoryHandler, BotBus>(storyDef, connectorDef) + } + + @ExperimentalTockCoroutines + override fun provide( + storyDef: AsyncStoryHandling, + connectorType: ConnectorType + ): AsyncConnectorHandlingBase<*>? { + val connectorDef = getConnectorHandlerMap(storyDef.javaClass.kotlin)[connectorType.id] + return provideConnectorStoryHandler, AsyncStoryHandling>(storyDef, connectorDef) + } + + @ExperimentalTockCoroutines + override fun provide( + storyDef: AsyncStoryHandling, + connectorId: String + ): AsyncConnectorHandlingBase<*>? { + val connectorDef = getConnectorIdHandlerMap(storyDef.javaClass.kotlin)[connectorId] + return provideConnectorStoryHandler, AsyncStoryHandling>(storyDef, connectorDef) } } diff --git a/bot/engine/src/main/kotlin/definition/DefinitionBuilders.kt b/bot/engine/src/main/kotlin/definition/DefinitionBuilders.kt index fdec471b3d..a691cbf3b0 100644 --- a/bot/engine/src/main/kotlin/definition/DefinitionBuilders.kt +++ b/bot/engine/src/main/kotlin/definition/DefinitionBuilders.kt @@ -151,7 +151,7 @@ fun story( /** * The [StoryStep] of the story if any. */ - steps: List> = emptyList(), + steps: List> = emptyList(), /** * Is this story unsupported for a [UserInterfaceType]? */ @@ -185,7 +185,7 @@ fun story( /** * The [StoryStep] of the story if any. */ - steps: List> = emptyList(), + steps: List> = emptyList(), /** * Is this story unsupported for a [UserInterfaceType]? */ @@ -225,7 +225,7 @@ inline fun story( /** * The [StoryStep] of the story if any. */ - steps: List> = emptyList(), + steps: List> = emptyList(), /** * Is this story unsupported for a [UserInterfaceType]? */ @@ -260,7 +260,7 @@ inline fun storyDef( /** * The [StoryStep] of the story if any. */ - steps: List> = emptyList(), + steps: List> = emptyList(), /** * Is this story unsupported for a [UserInterfaceType]? */ @@ -303,7 +303,7 @@ inline fun storyDef( /** * The [StoryStep] of the story if any. */ - steps: List> = emptyList(), + steps: List> = emptyList(), /** * Is this story unsupported for a [UserInterfaceType]? */ @@ -357,7 +357,7 @@ inline fun storyDefWithSteps( * [StoryHandlerDefinition.handle] is not called and the handling of bot answer is over. */ noinline preconditionsChecker: BotBus.() -> D -): StoryDefinitionBase where S : Enum, S : StoryStep = +): StoryDefinitionBase where S : Enum, S : StoryStep<*> = StoryDefinitionBase( intentName, ConfigurableStoryHandler(intentName, handlerDefCreator, preconditionsChecker), @@ -396,7 +396,7 @@ inline fun storyDefWithSteps( * [StoryHandlerDefinition.handle] is not called and the handling of bot answer is over. */ noinline preconditionsChecker: BotBus.() -> Unit -): StoryDefinitionBase where S : Enum, S : StoryStep = +): StoryDefinitionBase where S : Enum, S : StoryStep<*> = StoryDefinitionBase( intentName, ConfigurableStoryHandler(intentName, handlerDefCreator, preconditionsChecker), @@ -429,7 +429,7 @@ fun story( /** * The [StoryStep] of the story if any. */ - steps: List> = emptyList(), + steps: List> = emptyList(), /** * Is this story unsupported for a [UserInterfaceType]? */ @@ -465,7 +465,7 @@ inline fun storyWithSteps( */ unsupportedUserInterface: UserInterfaceType? = null ): StoryDefinitionBase - where T : Enum, T : StoryStep = + where T : Enum, T : StoryStep<*> = story( handler, handler, @@ -499,7 +499,7 @@ inline fun storyWithSteps( * Is this story unsupported for a [UserInterfaceType]? */ unsupportedUserInterface: UserInterfaceType? = null -): StoryDefinitionBase where T : Enum, T : StoryStep = +): StoryDefinitionBase where T : Enum, T : StoryStep<*> = story( intent, storyHandler, @@ -533,7 +533,7 @@ inline fun storyWithSteps( * The handler for the story. */ noinline handler: (BotBus).() -> Unit -): StoryDefinitionBase where T : Enum, T : StoryStep = +): StoryDefinitionBase where T : Enum, T : StoryStep<*> = story( intentName, otherStarterIntents, @@ -565,7 +565,7 @@ fun notify( botId: String, recipientId: PlayerId, intent: IntentAware, - step: StoryStep? = null, + step: StoryStepDef? = null, parameters: Parameters = Parameters.EMPTY, stateModifier: NotifyBotStateModifier = NotifyBotStateModifier.KEEP_CURRENT_STATE, notificationType: ActionNotificationType? = null, diff --git a/bot/engine/src/main/kotlin/definition/DialogContextKey.kt b/bot/engine/src/main/kotlin/definition/DialogContextKey.kt new file mode 100644 index 0000000000..e779117695 --- /dev/null +++ b/bot/engine/src/main/kotlin/definition/DialogContextKey.kt @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2017/2025 SNCF Connect & Tech + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ai.tock.bot.definition + +import kotlin.reflect.KClass + +class DialogContextKey(val type: KClass, val name: String) { + override fun toString(): String = name + + companion object { + inline operator fun invoke(name: String) = DialogContextKey(T::class, name) + } +} diff --git a/bot/engine/src/main/kotlin/definition/DialogFlowState.kt b/bot/engine/src/main/kotlin/definition/DialogFlowState.kt index a63b05002f..574f04331a 100644 --- a/bot/engine/src/main/kotlin/definition/DialogFlowState.kt +++ b/bot/engine/src/main/kotlin/definition/DialogFlowState.kt @@ -19,5 +19,5 @@ package ai.tock.bot.definition data class DialogFlowState( val storyDefinition: StoryDefinition, val intent: IntentAware = storyDefinition.mainIntent(), - val step: StoryStep<*>? = null + val step: StoryStepDef? = null ) diff --git a/bot/engine/src/main/kotlin/definition/I18nStoryHandler.kt b/bot/engine/src/main/kotlin/definition/I18nStoryHandler.kt new file mode 100644 index 0000000000..2e094072f9 --- /dev/null +++ b/bot/engine/src/main/kotlin/definition/I18nStoryHandler.kt @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2017/2025 SNCF Connect & Tech + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ai.tock.bot.definition + +import ai.tock.shared.InternalTockApi +import ai.tock.translator.I18nKeyProvider +import ai.tock.translator.I18nLabelValue +import ai.tock.translator.I18nLocalizedLabel + +/** + * A [StoryHandler] with i18n utilities. + * + * Story handlers should usually not directly extend this class, + * but instead extend [StoryHandlerBase] or [AsyncStoryHandlerBase]. + * + * @see StoryHandlerBase + * @see AsyncStoryHandlerBase + */ +interface I18nStoryHandler : StoryHandler, I18nKeyProvider { + fun i18nKey(key: String, defaultLabel: CharSequence, vararg args: Any?): I18nLabelValue + fun i18nKey(key: String, defaultLabel: CharSequence, defaultI18n: Set, vararg args: Any?): I18nLabelValue + + var i18nNamespace: String + @InternalTockApi set // should never be set by consumer code +} diff --git a/bot/engine/src/main/kotlin/definition/SimpleBotDefinition.kt b/bot/engine/src/main/kotlin/definition/SimpleBotDefinition.kt index f60e6a1cd7..d63f49f4df 100644 --- a/bot/engine/src/main/kotlin/definition/SimpleBotDefinition.kt +++ b/bot/engine/src/main/kotlin/definition/SimpleBotDefinition.kt @@ -17,6 +17,7 @@ package ai.tock.bot.definition import ai.tock.bot.engine.action.Action +import ai.tock.shared.InternalTockApi /** * A simple [BotDefinition]. @@ -74,7 +75,8 @@ class SimpleBotDefinition( keywordStory ) ).forEach { - (it.storyHandler as? StoryHandlerBase<*>)?.apply { + @OptIn(InternalTockApi::class) + (it.storyHandler as? I18nStoryHandler)?.apply { i18nNamespace = namespace } } diff --git a/bot/engine/src/main/kotlin/definition/SimpleStoryStep.kt b/bot/engine/src/main/kotlin/definition/SimpleStoryStep.kt index 4017d7770b..bc7bbcf768 100644 --- a/bot/engine/src/main/kotlin/definition/SimpleStoryStep.kt +++ b/bot/engine/src/main/kotlin/definition/SimpleStoryStep.kt @@ -16,7 +16,12 @@ package ai.tock.bot.definition +import ai.tock.shared.coroutines.ExperimentalTockCoroutines + /** * [StoryStep] without custom [StoryHandlerDefinition]. */ -interface SimpleStoryStep : StoryStep +@OptIn(ExperimentalTockCoroutines::class) +interface SimpleStoryStep : StoryStep, AsyncStoryStep { + override val children: Set get() = emptySet() +} diff --git a/bot/engine/src/main/kotlin/definition/StoryDefinition.kt b/bot/engine/src/main/kotlin/definition/StoryDefinition.kt index d3a25045ff..b267d0aa21 100644 --- a/bot/engine/src/main/kotlin/definition/StoryDefinition.kt +++ b/bot/engine/src/main/kotlin/definition/StoryDefinition.kt @@ -63,7 +63,7 @@ interface StoryDefinition : IntentAware { /** * The root steps of the story. */ - val steps: Set> + val steps: Set /** * True if the story handle metrics and is not a main tracked story @@ -99,10 +99,10 @@ interface StoryDefinition : IntentAware { /** * Returns all steps of the story. */ - fun allSteps(): Set> = - mutableSetOf>().apply { steps.forEach { allStep(this, it) } } + fun allSteps(): Set = + mutableSetOf().apply { steps.forEach { allStep(this, it) } } - private fun allStep(result: MutableSet>, step: StoryStep<*>) { + private fun allStep(result: MutableSet, step: StoryStepDef) { result.add(step) step.children.forEach { allStep(result, it) } } diff --git a/bot/engine/src/main/kotlin/definition/StoryDefinitionExtended.kt b/bot/engine/src/main/kotlin/definition/StoryDefinitionExtended.kt index 13b0d4fd63..ee8f76a5d0 100644 --- a/bot/engine/src/main/kotlin/definition/StoryDefinitionExtended.kt +++ b/bot/engine/src/main/kotlin/definition/StoryDefinitionExtended.kt @@ -33,8 +33,8 @@ interface StoryDefinitionExtended : StoryDefinition { /** * StoryStep implementation could be an enum */ - val stepsArray: Array> get() = emptyArray() - override val steps: Set> get() = stepsArray.toSet() + val stepsArray: Array get() = emptyArray() + override val steps: Set get() = stepsArray.toSet() val unsupportedUserInterface: UserInterfaceType? get() = null override val unsupportedUserInterfaces: Set get() = listOfNotNull(unsupportedUserInterface).toSet() diff --git a/bot/engine/src/main/kotlin/definition/StoryDefinitionWithSteps.kt b/bot/engine/src/main/kotlin/definition/StoryDefinitionWithSteps.kt new file mode 100644 index 0000000000..95ba870358 --- /dev/null +++ b/bot/engine/src/main/kotlin/definition/StoryDefinitionWithSteps.kt @@ -0,0 +1,21 @@ +/* + * Copyright (C) 2017/2025 SNCF Connect & Tech + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ai.tock.bot.definition + +interface StoryDefinitionWithSteps : StoryDefinition { + override val steps: Set +} diff --git a/bot/engine/src/main/kotlin/definition/StoryHandlerBase.kt b/bot/engine/src/main/kotlin/definition/StoryHandlerBase.kt index 07a305e332..3f23895574 100644 --- a/bot/engine/src/main/kotlin/definition/StoryHandlerBase.kt +++ b/bot/engine/src/main/kotlin/definition/StoryHandlerBase.kt @@ -20,6 +20,7 @@ import ai.tock.bot.definition.BotDefinition.Companion.defaultBreath import ai.tock.bot.engine.BotBus import ai.tock.bot.engine.action.SendSentence import ai.tock.bot.engine.hasCurrentSwitchStoryProcess +import ai.tock.shared.InternalTockApi import ai.tock.shared.defaultNamespace import ai.tock.translator.I18nKeyProvider import ai.tock.translator.I18nKeyProvider.Companion.generateKey @@ -40,12 +41,13 @@ abstract class StoryHandlerBase( * The namespace for [I18nKeyProvider] implementation. */ @Volatile - internal var i18nNamespace: String = defaultNamespace, + @set:InternalTockApi + override var i18nNamespace: String = defaultNamespace, /** * Convenient value to wait before next answer sentence. */ val breath: Long = defaultBreath -) : StoryHandler, I18nKeyProvider, IntentAware { +) : I18nStoryHandler, IntentAware { companion object { private val logger = KotlinLogging.logger {} @@ -93,8 +95,10 @@ abstract class StoryHandlerBase( ): StoryStep<*>? { storyDefinition?.steps?.also { steps -> for (s in steps) { + s as StoryStep<*> + @Suppress("UNCHECKED_CAST") val selected = if (s is StoryDataStep<*, *, *>) { - (s as? StoryDataStep)?.selectFromBusAndData()?.invoke(def, data) ?: false + (s as? StoryDataStep)?.selectFromBusAndData()?.invoke(def, data) ?: false } else { s.selectFromBus().invoke(def) } @@ -149,7 +153,7 @@ abstract class StoryHandlerBase( !bus.hasCurrentSwitchStoryProcess && !isEndCalled(bus) ) { - logger.warn { "Bus.end not called for story ${bus.story.definition.id}, user ${bus.userId.id} and connector ${bus.targetConnectorType}" } + logger.warn { "Bus.end not called for story ${(storyDefinition ?: bus.story.definition).id}, user ${bus.userId.id} and connector ${bus.targetConnectorType}" } } } } @@ -201,14 +205,14 @@ abstract class StoryHandlerBase( /** * Gets an i18n label with the specified key. Current namespace is used for the categorization. */ - fun i18nKey(key: String, defaultLabel: CharSequence, vararg args: Any?): I18nLabelValue { + override fun i18nKey(key: String, defaultLabel: CharSequence, vararg args: Any?): I18nLabelValue { return i18nKey(key, defaultLabel, emptySet(), *args) } /** * Gets an i18n label with the specified key and defaults. Current namespace is used for the categorization. */ - fun i18nKey(key: String, defaultLabel: CharSequence, defaultI18n: Set, vararg args: Any?): I18nLabelValue { + override fun i18nKey(key: String, defaultLabel: CharSequence, defaultI18n: Set, vararg args: Any?): I18nLabelValue { val category = i18nKeyCategory() return I18nLabelValue( key, diff --git a/bot/engine/src/main/kotlin/definition/StoryHandlerDefinitionBase.kt b/bot/engine/src/main/kotlin/definition/StoryHandlerDefinitionBase.kt index 8906422e91..c284eb260b 100644 --- a/bot/engine/src/main/kotlin/definition/StoryHandlerDefinitionBase.kt +++ b/bot/engine/src/main/kotlin/definition/StoryHandlerDefinitionBase.kt @@ -23,10 +23,6 @@ import ai.tock.bot.engine.BotBus import ai.tock.shared.injector import ai.tock.shared.provide import kotlin.LazyThreadSafetyMode.PUBLICATION -import kotlin.reflect.KClass -import kotlin.reflect.full.isSubtypeOf -import kotlin.reflect.full.primaryConstructor -import kotlin.reflect.full.starProjectedType import mu.KotlinLogging /** @@ -67,17 +63,6 @@ abstract class StoryHandlerDefinitionBase>(val */ val connectorType: ConnectorType = bus.targetConnectorType - @Suppress("UNCHECKED_CAST") - private fun provideConnectorStoryHandler(connectorDef: KClass<*>?): T? { - return connectorDef?.primaryConstructor?.callBy( - mapOf( - connectorDef.primaryConstructor!!.parameters.first { - it.type.isSubtypeOf(BotBus::class.starProjectedType) - } to this - ) - ) as T? - } - /** * Method to override in order to provide [ConnectorStoryHandler]. * Default implementation use annotations annotated with @[ConnectorHandler]. diff --git a/bot/engine/src/main/kotlin/definition/StoryStep.kt b/bot/engine/src/main/kotlin/definition/StoryStep.kt index 6e14e75648..7e3609c9c5 100644 --- a/bot/engine/src/main/kotlin/definition/StoryStep.kt +++ b/bot/engine/src/main/kotlin/definition/StoryStep.kt @@ -16,17 +16,13 @@ package ai.tock.bot.definition -import ai.tock.bot.admin.story.StoryDefinitionStepMetric import ai.tock.bot.engine.BotBus -import ai.tock.bot.engine.action.Action -import ai.tock.bot.engine.dialog.Dialog -import ai.tock.bot.engine.user.UserTimeline import java.util.concurrent.ConcurrentHashMap /** * step -> intent default behaviour. */ -internal val stepToIntentRepository = ConcurrentHashMap, IntentAware>() +internal val stepToIntentRepository = ConcurrentHashMap() /** * Use this step when you want to set a null [StoryStep]. @@ -39,12 +35,7 @@ val noStep = object : SimpleStoryStep { * A step is a part of a [StoryDefinition]. * Used to manage workflow in a [StoryHandler]. */ -interface StoryStep { - - /** - * The name of the step. - */ - val name: String +interface StoryStep : StoryStepDef { /** * The custom answer for this step. @@ -56,99 +47,18 @@ interface StoryStep { fun answer(): T.() -> Any? = { null } /** - * Returns [intent] or the [StoryDefinition.mainIntent] if [intent] is null. - */ - val baseIntent: IntentAware get() = intent ?: stepToIntentRepository[this] ?: error("no intent for $this") - - /** - * The main intent of the step. - * If not null and if the current intent is equals to [intent], - * this step will be automatically selected to be the current step. - */ - val intent: IntentAware? get() = null - - /** - * Same behaviour than [intent] in the rare case when the step handle more than one intent. - */ - val otherStarterIntents: Set get() = emptySet() - - /** - * The secondary intents of this step. If detected and if the current step is this step, - * the current step remains this step. - */ - val secondaryIntents: Set get() = emptySet() - - /** - * Does this Step has to be selected from the Bus? + * Does this Step have to be selected for the current context? + * * This method is called if [StoryHandlerBase.checkPreconditions] does not call [BotBus.end]. - * If this functions returns true, the step is selected and remaining steps are not tested. + * If this method returns true, the step is selected and remaining steps are not tested. + * This method is called even if [selectFromAction] previously returned `false`. + * + * Returning `true` causes the step to be selected even if another step got previously selected. + * Returning `false` does not deselect the step if it was already selected. + * + * @see selectFromAction */ fun selectFromBus(): BotBus.() -> Boolean = { false } - /** - * Does this Step has to be automatically selected from the action context? - * if returns true, the step is selected. - */ - fun selectFromAction(userTimeline: UserTimeline, dialog: Dialog, action: Action, intent: Intent?): Boolean = - intent != null && selectFromActionAndEntityStepSelection(action, intent) ?: supportStarterIntent(intent) - - /** - * Does this Step has to be automatically selected from the dialog context? - * if returns true, the step is selected. - */ - fun selectFromDialog(userTimeline: UserTimeline, dialog: Dialog, intent: Intent?): Boolean = - intent != null && selectFromDialogAndEntityStepSelection(dialog, intent) ?: supportStarterIntent(intent) - - /** - * Does this step hast to be selected from its [entityStepSelection]? - * Returns null if there is no [entityStepSelection]. - */ - fun selectFromActionAndEntityStepSelection(action: Action, intent: Intent? = null): Boolean? = - entityStepSelection?.let { e -> - if (intent != null && this.intent != null && !supportStarterIntent(intent)) false - else if (e.value == null) action.hasEntity(e.entityRole) - else action.hasEntityPredefinedValue(e.entityRole, e.value) - } - - /** - * Does this step hast to be selected from its [entityStepSelection]? - * Returns null if there is no [entityStepSelection]. - */ - fun selectFromDialogAndEntityStepSelection(dialog: Dialog, intent: Intent? = null): Boolean? = - entityStepSelection?.let { e -> - if (intent != null && this.intent != null && !supportStarterIntent(intent)) false - else if (e.value == null) dialog.state.hasEntity(e.entityRole) - else dialog.state.hasEntityPredefinedValue(e.entityRole, e.value) - } - - /** - * Does this step support this intent as starter intent? - */ - fun supportStarterIntent(i: Intent): Boolean = - intent?.wrap(i) == true || otherStarterIntents.any { it.wrap(i) } - - /** - * Does this step support this intent? - */ - fun supportIntent(i: Intent): Boolean = supportStarterIntent(i) || secondaryIntents.any { it.wrap(i) } - - /** - * The optional children of the step. - */ - val children: Set> get() = emptySet() - - /** - * Flag indicating if it's the step has no children. - */ - val hasNoChildren: Boolean get() = children.isEmpty() - - /** - * If not null, entity has to be set in the current action to trigger the step. - */ - val entityStepSelection: EntityStepSelection? get() = null - - /** - * The step metrics. - */ - val metrics: List get() = emptyList() + override val children: Set> get() = emptySet() } diff --git a/bot/engine/src/main/kotlin/definition/StoryStepDef.kt b/bot/engine/src/main/kotlin/definition/StoryStepDef.kt new file mode 100644 index 0000000000..2e6261ff15 --- /dev/null +++ b/bot/engine/src/main/kotlin/definition/StoryStepDef.kt @@ -0,0 +1,109 @@ +/* + * Copyright (C) 2017/2025 SNCF Connect & Tech + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ai.tock.bot.definition + +import ai.tock.bot.admin.story.StoryDefinitionStepMetric +import ai.tock.bot.engine.action.Action +import ai.tock.bot.engine.dialog.Dialog +import ai.tock.bot.engine.user.UserTimeline + +/** + * @see StoryStep + * @see AsyncStoryStep + */ +interface StoryStepDef { + + /** + * The name of the step. + */ + val name: String + + /** + * Returns [intent] or the [StoryDefinition.mainIntent] if [intent] is null. + */ + val baseIntent: IntentAware get() = intent ?: stepToIntentRepository[this] ?: error("no intent for $this") + + /** + * The main intent of the step. + * If not null and if the current intent is equals to [intent], + * this step will be automatically selected to be the current step. + */ + val intent: IntentAware? get() = null + + /** + * Same behaviour than [intent] in the rare case when the step handle more than one intent. + */ + val otherStarterIntents: Set get() = emptySet() + + /** + * The secondary intents of this step. If detected and if the current step is this step, + * the current step remains this step. + */ + val secondaryIntents: Set get() = emptySet() + + /** + * Should this Step be automatically selected from the action context? + * + * If this method returns `false`, the step may still be selected by other mechanisms (e.g. quick replies). + * + * @return true if the step should be selected + */ + fun selectFromAction(userTimeline: UserTimeline, dialog: Dialog, action: Action, intent: Intent?): Boolean = + intent != null && selectFromActionAndEntityStepSelection(action, intent) ?: supportStarterIntent(intent) + + /** + * Does this step hast to be selected from its [entityStepSelection]? + * Returns null if there is no [entityStepSelection]. + */ + fun selectFromActionAndEntityStepSelection(action: Action, intent: Intent? = null): Boolean? = + entityStepSelection?.let { e -> + if (intent != null && this.intent != null && !supportStarterIntent(intent)) false + else if (e.value == null) action.hasEntity(e.entityRole) + else action.hasEntityPredefinedValue(e.entityRole, e.value) + } + + /** + * Does this step support this intent as starter intent? + */ + fun supportStarterIntent(i: Intent): Boolean = + intent?.wrap(i) == true || otherStarterIntents.any { it.wrap(i) } + + /** + * Does this step support this intent? + */ + fun supportIntent(i: Intent): Boolean = supportStarterIntent(i) || secondaryIntents.any { it.wrap(i) } + + /** + * The optional children of the step. + */ + val children: Set get() = emptySet() + + /** + * Flag indicating if it's the step has no children. + */ + val hasNoChildren: Boolean get() = children.isEmpty() + + /** + * If not null, entity has to be set in the current action to trigger the step. + */ + val entityStepSelection: EntityStepSelection? get() = null + + /** + * The step metrics. + */ + val metrics: List get() = emptyList() +} diff --git a/bot/engine/src/main/kotlin/engine/AsyncBotBus.kt b/bot/engine/src/main/kotlin/engine/AsyncBotBus.kt new file mode 100644 index 0000000000..b15ba18e99 --- /dev/null +++ b/bot/engine/src/main/kotlin/engine/AsyncBotBus.kt @@ -0,0 +1,270 @@ +/* + * Copyright (C) 2017/2025 SNCF Connect & Tech + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ai.tock.bot.engine + +import ai.tock.bot.connector.ConnectorType +import ai.tock.bot.definition.AsyncStoryHandler +import ai.tock.bot.definition.AsyncStoryStep +import ai.tock.bot.definition.DialogContextKey +import ai.tock.bot.definition.Intent +import ai.tock.bot.definition.IntentAware +import ai.tock.bot.definition.ParameterKey +import ai.tock.bot.definition.StoryDefinition +import ai.tock.bot.definition.StoryStepDef +import ai.tock.bot.engine.dialog.EntityValue +import ai.tock.bot.engine.dialog.NextUserActionState +import ai.tock.bot.engine.dialog.Story +import ai.tock.bot.engine.feature.FeatureDAO +import ai.tock.bot.engine.feature.FeatureType +import ai.tock.bot.engine.message.MessagesList +import ai.tock.bot.engine.message.MessagesList.Companion.toMessageList +import ai.tock.bot.engine.user.PlayerId +import ai.tock.bot.engine.user.UserPreferences +import ai.tock.bot.engine.user.UserState +import ai.tock.nlp.api.client.model.Entity +import ai.tock.nlp.entity.Value +import ai.tock.shared.Executor +import ai.tock.shared.coroutines.ExperimentalTockCoroutines +import ai.tock.shared.injector +import ai.tock.shared.provide +import ai.tock.translator.I18nLabelValue +import ai.tock.translator.I18nLocalizedLabel +import ai.tock.translator.UserInterfaceType +import java.util.Locale +import kotlin.coroutines.CoroutineContext +import kotlin.reflect.safeCast +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.withContext + +@ExperimentalTockCoroutines +open class AsyncBotBus(val botBus: BotBus) : AsyncBus { + companion object { + /** + * Helper method to retrieve the current bus, + * linked to the coroutine currently used by the handler. + */ + suspend fun retrieveCurrentBus(): AsyncBotBus? = currentCoroutineContext()[Ref]?.bus + } + + private val executor: Executor get() = injector.provide() + private val featureDao: FeatureDAO get() = injector.provide() + + override val connectorId: String + get() = botBus.connectorId + override val targetConnectorType: ConnectorType + get() = botBus.targetConnectorType + override val botId: PlayerId + get() = botBus.botId + override val userId: PlayerId + get() = botBus.userId + override val userLocale: Locale + get() = botBus.userLocale + override val userInterfaceType: UserInterfaceType + get() = botBus.userInterfaceType + override val intent: IntentAware? + get() = botBus.intent + override val currentIntent: IntentAware? + get() = botBus.currentIntent + override val currentStoryDefinition: StoryDefinition + get() = story.definition + override var step: AsyncStoryStep<*>? + get() = story.currentStep as? AsyncStoryStep<*> + set(step) { + story.step = step?.name + } + override val userInfo: UserPreferences + get() = botBus.userPreferences + override val userState: UserState + get() = botBus.userTimeline.userState + val story: Story get() = botBus.story + + override fun defaultAnswerDelay() = botBus.defaultDelay(botBus.currentAnswerIndex) + + override suspend fun constrainNlp(nextActionState: NextUserActionState) { + botBus.nextUserActionState = nextActionState + } + + override fun choice(key: ParameterKey): String? { + return botBus.choice(key) + } + + override fun booleanChoice(key: ParameterKey): Boolean { + return botBus.booleanChoice(key) + } + + override fun hasActionEntity(role: String): Boolean { + return botBus.hasActionEntity(role) + } + + override fun entityValue( + role: String, + valueTransformer: (EntityValue) -> T? + ): T? { + return synchronized(botBus) { botBus.entityValue(role, valueTransformer) } + } + + override fun entityValueDetails(role: String): EntityValue? { + return synchronized(botBus) { botBus.entityValueDetails(role) } + } + + override fun changeEntityValue(role: String, newValue: EntityValue?) { + synchronized(botBus) { botBus.changeEntityValue(role, newValue) } + } + + override fun changeEntityValue(entity: Entity, newValue: Value?) { + return synchronized (botBus) { botBus.changeEntityValue(entity, newValue) } + } + + override fun removeAllEntityValues() { + // Synchronized to avoid ConcurrentModificationException with other entity setters + synchronized(botBus) { + botBus.removeAllEntityValues() + } + } + + override fun getContextValue(key: DialogContextKey): T? { + return botBus.dialog.state.context[key.name]?.let(key.type::safeCast) + } + + override fun setContextValue(key: DialogContextKey, value: T?) { + botBus.dialog.state.setContextValue(key, value) + } + + override fun setBusContextValue(key: DialogContextKey, value: T?) { + botBus.setBusContextValue(key.name, value) + } + + override fun getBusContextValue(key: DialogContextKey): T? { + return botBus.getBusContextValue(key.name)?.let(key.type::safeCast) + } + + override suspend fun isFeatureEnabled( + feature: FeatureType, + default: Boolean + ): Boolean { + // TODO replace worker thread offloading with suspend variant of FeatureDao.isEnabled + return withContext(executor.asCoroutineDispatcher()) { + featureDao.isEnabled(botBus.botDefinition.botId, botBus.botDefinition.namespace, feature, connectorId, default, userId.id) + } + } + + override suspend fun handleAndSwitchStory( + storyDefinition: StoryDefinition, + starterIntent: Intent, + step: StoryStepDef?, + ) { + synchronized(botBus) { + botBus.stepDef = step + botBus.switchStory(storyDefinition, starterIntent) + botBus.hasCurrentSwitchStoryProcess = false + } + (storyDefinition.storyHandler as? AsyncStoryHandler)?.handle(this) + ?: storyDefinition.storyHandler.handle(botBus) + } + + override fun i18nWithKey( + key: String, + defaultLabel: String, + vararg args: Any? + ): I18nLabelValue { + return botBus.i18nKey(key, defaultLabel, *args) + } + + override fun i18nWithKey( + key: String, + defaultLabel: String, + defaultI18n: Set, + vararg args: Any? + ): I18nLabelValue { + return botBus.i18nKey(key, defaultLabel, defaultI18n, *args) + } + + override fun i18n( + defaultLabel: CharSequence, + args: List + ): I18nLabelValue { + return botBus.i18n(defaultLabel, args) + } + + override suspend fun send(i18nText: CharSequence, delay: Long) { + withContext(executor.asCoroutineDispatcher()) { + synchronized(botBus) { + botBus.send(i18nText, delay = delay) + } + } + } + + override suspend fun send(i18nText: String, vararg i18nArgs: Any?) { + withContext(executor.asCoroutineDispatcher()) { + synchronized(botBus) { + botBus.send(i18nText, *i18nArgs) + } + } + } + + override suspend fun end(i18nText: CharSequence, delay: Long) { + withContext(executor.asCoroutineDispatcher()) { + synchronized(botBus) { + botBus.end(i18nText, delay = delay) + } + } + } + + override suspend fun end(i18nText: String, vararg i18nArgs: Any?) { + withContext(executor.asCoroutineDispatcher()) { + synchronized(botBus) { + botBus.end(i18nText, *i18nArgs) + } + } + } + + override suspend fun send(delay: Long, messageProvider: Bus<*>.() -> Any?) { + val messages = toMessageList(messageProvider) + synchronized(botBus) { + if (messages.messages.isEmpty()) { + botBus.send(delay) + } else { + botBus.send(messages, delay) + } + } + } + + override suspend fun end(delay: Long, messageProvider: Bus<*>.() -> Any?) { + val messages = toMessageList(messageProvider) + synchronized(botBus) { + if (messages.messages.isEmpty()) { + botBus.end(delay) + } else { + botBus.end(messages, delay) + } + } + } + + protected open suspend fun toMessageList(messageProvider: Bus<*>.() -> Any?): MessagesList = + // calls to `translate` are blocking (database and possibly translator API), + // so we ensure they are done in a worker thread + withContext(executor.asCoroutineDispatcher()) { + toMessageList(null, botBus, messageProvider) + } + + data class Ref(val bus: AsyncBotBus): CoroutineContext.Element { + companion object Key : CoroutineContext.Key + + override val key = Key + } +} diff --git a/bot/engine/src/main/kotlin/engine/AsyncBus.kt b/bot/engine/src/main/kotlin/engine/AsyncBus.kt new file mode 100644 index 0000000000..1d2983cfea --- /dev/null +++ b/bot/engine/src/main/kotlin/engine/AsyncBus.kt @@ -0,0 +1,206 @@ +/* + * Copyright (C) 2017/2025 SNCF Connect & Tech + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ai.tock.bot.engine + +import ai.tock.bot.connector.ConnectorMessage +import ai.tock.bot.connector.ConnectorMessageProvider +import ai.tock.bot.connector.ConnectorType +import ai.tock.bot.definition.AsyncStoryStep +import ai.tock.bot.definition.DialogContextKey +import ai.tock.bot.definition.Intent +import ai.tock.bot.definition.IntentAware +import ai.tock.bot.definition.ParameterKey +import ai.tock.bot.definition.StoryDefinition +import ai.tock.bot.definition.StoryDefinitionWithSteps +import ai.tock.bot.definition.StoryStepDef +import ai.tock.bot.engine.action.SendChoice +import ai.tock.bot.engine.dialog.NextUserActionState +import ai.tock.bot.engine.feature.FeatureType +import ai.tock.bot.engine.message.Message +import ai.tock.bot.engine.user.PlayerId +import ai.tock.bot.engine.user.UserPreferences +import ai.tock.bot.engine.user.UserState +import ai.tock.shared.coroutines.ExperimentalTockCoroutines +import ai.tock.translator.I18nKeyProvider +import ai.tock.translator.I18nLabelValue +import ai.tock.translator.I18nLocalizedLabel +import ai.tock.translator.UserInterfaceType +import java.util.Locale + +@ExperimentalTockCoroutines +interface AsyncBus : DialogEntityAccess, I18nKeyProvider { + /** + * The connector ID. + */ + val connectorId: String + + val targetConnectorType: ConnectorType + + /** + * The current bot id. + */ + val botId: PlayerId + + /** + * The locale in which the bot should answer + * + * This locale generally corresponds to the user's selected locale. + * When the user's specific locale is not supported by the chatbot (as defined in the admin interface), + * the selected locale will be the closest available match, defaulting to the [ai.tock.shared.defaultLocale]. + */ + val userLocale: Locale + + /** + * The current user interface type. + */ + val userInterfaceType: UserInterfaceType + + /** + * The current user id. + */ + val userId: PlayerId + + val userInfo: UserPreferences + + val userState: UserState + + /** + * The current intent of the dialog at Bus (ie request) initialization. + */ + val intent: IntentAware? + + /** + * The current intent for this user (may be different from the initial [intent]). + */ + val currentIntent: IntentAware? + + val currentStoryDefinition: StoryDefinition + + var step: AsyncStoryStep<*>? + + /** + * Checks if the [currentIntent] and [checked] match + */ + fun matchesIntent(checked: IntentAware): Boolean = checked.wrap(currentIntent?.wrappedIntent()) + + suspend fun constrainNlp(nextActionState: NextUserActionState) + + /** + * Returns true if the current action has the specified entity role. + */ + fun hasActionEntity(role: String): Boolean + + /** + * Returns the value of the specified choice parameter, null if the user action is not a [SendChoice] + * or if this parameter is not set. + * + * @see booleanChoice + */ + fun choice(key: ParameterKey): String? + + /** + * Returns true if the specified choice parameter has the "true" value, false either. + * + * @see choice + */ + fun booleanChoice(key: ParameterKey): Boolean + + /** + * Returns the persistent current context value for the given [key], or `null` if + * no such value is present in the dialog state. + */ + fun getContextValue(key: DialogContextKey): T? + + /** + * Updates persistent context value. + * Do not store Collection or Map in the context, only plain objects or typed arrays. + */ + fun setContextValue(key: DialogContextKey, value: T?) + + /** + * Updates the non persistent current context value. + * Bus context values are useful to store a temporary (ie request scoped) state. + */ + fun setBusContextValue(key: DialogContextKey, value: T?) + + /** + * Returns the non persistent current context value. + * Bus context values are useful to store a temporary (ie request scoped) state. + */ + fun getBusContextValue(key: DialogContextKey): T? + + /** + * Is the feature enabled? + * + * @param feature the feature to check + * @param default the default value if the feature state is unknown + */ + suspend fun isFeatureEnabled(feature: FeatureType, default: Boolean = false): Boolean + + suspend fun handleAndSwitchStory( + storyDefinition: StoryDefinition, + starterIntent: Intent = storyDefinition.mainIntent(), + step: StoryStepDef? = null, + ) + + suspend fun handleAndSwitchStory( + storyDefinition: StoryDefinitionWithSteps, + starterIntent: Intent = storyDefinition.mainIntent(), + step: S? = null, + ) { + handleAndSwitchStory(storyDefinition as StoryDefinition, starterIntent, step) + } + + fun i18nWithKey(key: String, defaultLabel: String, vararg args: Any?): I18nLabelValue + fun i18nWithKey(key: String, defaultLabel: String, defaultI18n: Set, vararg args: Any?): I18nLabelValue + + suspend fun send(i18nText: CharSequence, delay: Long = defaultAnswerDelay()) + suspend fun send(i18nText: String, vararg i18nArgs: Any?) + suspend fun end(i18nText: CharSequence, delay: Long = defaultAnswerDelay()) + suspend fun end(i18nText: String, vararg i18nArgs: Any?) + + /** + * [messageProvider] must return an instance or a collection of the following possible types: + * - [CharSequence] ([String], [ai.tock.translator.I18nLabelValue], or [ai.tock.translator.RawString]) + * - [ConnectorMessageProvider] (typically a [ConnectorMessage] for the current [Bus.targetConnectorType]) + * - [Message] (typically a [ai.tock.bot.engine.message.Sentence]) + * + * @param delay the delay between the previous message and this one + */ + suspend fun send( + delay: Long = defaultAnswerDelay(), + messageProvider: Bus<*>.() -> Any? + ) + + /** + * [messageProvider] must return an instance or a collection of the following possible types: + * - [CharSequence] ([String], [ai.tock.translator.I18nLabelValue], or [ai.tock.translator.RawString]) + * - [ConnectorMessageProvider] (typically a [ConnectorMessage] for the current [Bus.targetConnectorType]) + * - [Message] (typically a [ai.tock.bot.engine.message.Sentence]) + * + * @param delay the delay between the previous message and this one + */ + suspend fun end( + delay: Long = defaultAnswerDelay(), + messageProvider: Bus<*>.() -> Any? + ) + + /** + * @see ai.tock.bot.definition.BotDefinition.defaultDelay + */ + fun defaultAnswerDelay(): Long +} diff --git a/bot/engine/src/main/kotlin/engine/Bot.kt b/bot/engine/src/main/kotlin/engine/Bot.kt index 3492c842b6..441566a01c 100644 --- a/bot/engine/src/main/kotlin/engine/Bot.kt +++ b/bot/engine/src/main/kotlin/engine/Bot.kt @@ -33,11 +33,13 @@ import ai.tock.bot.engine.dialog.Story import ai.tock.bot.engine.feature.DefaultFeatureType import ai.tock.bot.engine.nlp.NlpController import ai.tock.bot.engine.user.UserTimeline +import ai.tock.shared.coroutines.ExperimentalTockCoroutines import ai.tock.shared.injector import com.github.salomonbrys.kodein.instance import java.util.Locale +import kotlinx.coroutines.asContextElement +import kotlinx.coroutines.withContext import mu.KotlinLogging -import kotlin.time.Duration /** * @@ -80,7 +82,8 @@ internal class Bot(botDefinitionBase: BotDefinition, val configuration: BotAppli /** * Handle the user action. */ - fun handle(action: Action, userTimeline: UserTimeline, connector: ConnectorController, connectorData: ConnectorData) { + @ExperimentalTockCoroutines + suspend fun handleAction(action: Action, userTimeline: UserTimeline, connector: ConnectorController, connectorData: ConnectorData) { connector as TockConnectorController loadProfileIfNotSet(connectorData, action, userTimeline, connector) @@ -108,26 +111,28 @@ internal class Bot(botDefinitionBase: BotDefinition, val configuration: BotAppli if (!userTimeline.userState.botDisabled) { dialog.state.currentIntent?.let { intent -> - connector.sendIntent(intent, action.applicationId, connectorData) + connector.sendIntent(intent, action.connectorId, connectorData) } connector.startTypingInAnswerTo(action, connectorData) val story = getStory(userTimeline, dialog, action) val bus = TockBotBus(connector, userTimeline, dialog, action, connectorData, botDefinition) - - if (bus.isFeatureEnabled(DefaultFeatureType.DISABLE_BOT)) { - logger.info { "bot is disabled for the application" } - bus.end("Bot is disabled") - return - } - - try { - currentBus.set(bus) - story.handle(bus) - if (shouldRespondBeforeDisabling) { - userTimeline.userState.botDisabled = true + val asyncBus = AsyncBotBus(bus) + + withContext(AsyncBotBus.Ref(asyncBus) + currentBus.asContextElement(bus)) { + val closeMessageQueue = bus.deferMessageSending(this) + + if (asyncBus.isFeatureEnabled(DefaultFeatureType.DISABLE_BOT)) { + logger.info { "bot is disabled for the application" } + asyncBus.end("Bot is disabled") + } else { + story.handle(asyncBus) + if (shouldRespondBeforeDisabling) { + userTimeline.userState.botDisabled = true + } } - } finally { - currentBus.remove() + + // Ensure we do not have a lingering message sending job + closeMessageQueue() } } else { // refresh intent flag diff --git a/bot/engine/src/main/kotlin/engine/BotBus.kt b/bot/engine/src/main/kotlin/engine/BotBus.kt index f3239cbf31..74a79d2521 100644 --- a/bot/engine/src/main/kotlin/engine/BotBus.kt +++ b/bot/engine/src/main/kotlin/engine/BotBus.kt @@ -23,14 +23,16 @@ import ai.tock.bot.connector.ConnectorConfiguration import ai.tock.bot.connector.ConnectorData import ai.tock.bot.connector.ConnectorMessage import ai.tock.bot.connector.ConnectorType +import ai.tock.bot.definition.AsyncStoryDefinition import ai.tock.bot.definition.BotDefinition +import ai.tock.bot.definition.I18nStoryHandler import ai.tock.bot.definition.Intent import ai.tock.bot.definition.IntentAware import ai.tock.bot.definition.ParameterKey import ai.tock.bot.definition.StoryDefinition -import ai.tock.bot.definition.StoryHandlerBase import ai.tock.bot.definition.StoryHandlerDefinition import ai.tock.bot.definition.StoryStep +import ai.tock.bot.definition.StoryStepDef import ai.tock.bot.engine.action.Action import ai.tock.bot.engine.action.ActionNotificationType import ai.tock.bot.engine.action.ActionPriority @@ -53,6 +55,7 @@ import ai.tock.bot.engine.user.UserPreferences import ai.tock.bot.engine.user.UserTimeline import ai.tock.nlp.api.client.model.Entity import ai.tock.nlp.entity.Value +import ai.tock.shared.coroutines.ExperimentalTockCoroutines import ai.tock.shared.injector import ai.tock.shared.provide import ai.tock.translator.I18nKeyProvider @@ -63,7 +66,7 @@ import java.util.Locale /** * Bus implementation for Tock integrated mode. */ -interface BotBus : Bus { +interface BotBus : Bus, DialogEntityAccess { companion object { /** @@ -152,6 +155,12 @@ interface BotBus : Bus { var nextUserActionState: NextUserActionState? var step: StoryStep? + get() = stepDef as? StoryStep + set(step) { + stepDef = step + } + + var stepDef: StoryStepDef? get() = story.currentStep set(step) { story.step = step?.name @@ -207,50 +216,54 @@ interface BotBus : Bus { */ fun hasActionEntity(entity: Entity): Boolean = hasActionEntity(entity.role) + fun entityValue(role: String): T? = entityValue(role, @Suppress("UNCHECKED_CAST") { it.value as? T? }) + /** * Returns the current value for the specified entity role. */ - fun entityValue( + override fun entityValue( role: String, - valueTransformer: (EntityValue) -> T? = @Suppress("UNCHECKED_CAST") { it.value as? T? } + valueTransformer: (EntityValue) -> T? ): T? { return entities[role]?.value?.let { valueTransformer.invoke(it) } } + fun entityValue(entity: Entity): T? = entityValue(entity, @Suppress("UNCHECKED_CAST") { it.value as? T? }) + /** * Returns the current value for the specified entity. */ - fun entityValue( + override fun entityValue( entity: Entity, - valueTransformer: (EntityValue) -> T? = @Suppress("UNCHECKED_CAST") { it.value as? T? } + valueTransformer: (EntityValue) -> T? ): T? = entityValue(entity.role, valueTransformer) /** * Returns the current text content for the specified entity. */ - fun entityText(entity: Entity): String? = entityValueDetails(entity)?.content + override fun entityText(entity: Entity): String? = entityValueDetails(entity)?.content /** * Returns the current text content for the specified entity. */ - fun entityText(role: String): String? = entityValueDetails(role)?.content + override fun entityText(role: String): String? = entityValueDetails(role)?.content /** * Returns the current [EntityValue] for the specified entity. */ - fun entityValueDetails(entity: Entity): EntityValue? = entityValueDetails(entity.role) + override fun entityValueDetails(entity: Entity): EntityValue? = entityValueDetails(entity.role) /** * Returns the current [EntityValue] for the specified role. */ - fun entityValueDetails(role: String): EntityValue? = entities[role]?.value + override fun entityValueDetails(role: String): EntityValue? = entities[role]?.value /** * Updates the current entity value in the dialog. * @param role entity role * @param newValue the new entity value */ - fun changeEntityValue(role: String, newValue: EntityValue?) { + override fun changeEntityValue(role: String, newValue: EntityValue?) { dialog.state.changeValue(role, newValue) } @@ -259,7 +272,7 @@ interface BotBus : Bus { * @param entity the entity definition * @param newValue the new entity value */ - fun changeEntityValue(entity: Entity, newValue: Value?) { + override fun changeEntityValue(entity: Entity, newValue: Value?) { dialog.state.changeValue(entity, newValue) } @@ -268,14 +281,14 @@ interface BotBus : Bus { * @param entity the entity definition * @param newValue the new entity value */ - fun changeEntityValue(entity: Entity, newValue: EntityValue) = changeEntityValue(entity.role, newValue) + override fun changeEntityValue(entity: Entity, newValue: EntityValue) = changeEntityValue(entity.role, newValue) /** * Updates the current entity text value in the dialog. * @param entity the entity definition * @param textContent the new entity text content */ - fun changeEntityText(entity: Entity, textContent: String?) = + override fun changeEntityText(entity: Entity, textContent: String?) = changeEntityValue( entity.role, EntityValue(entity, null, textContent) @@ -284,19 +297,19 @@ interface BotBus : Bus { /** * Removes entity value for the specified role. */ - fun removeEntityValue(role: String) { + override fun removeEntityValue(role: String) { dialog.state.resetValue(role) } /** * Removes entity value for the specified role. */ - fun removeEntityValue(entity: Entity) = removeEntityValue(entity.role) + override fun removeEntityValue(entity: Entity) = removeEntityValue(entity.role) /** * Removes all current entity values. */ - fun removeAllEntityValues() { + override fun removeAllEntityValues() { dialog.state.resetAllEntityValues() } @@ -462,6 +475,12 @@ interface BotBus : Bus { storyDefinition.storyHandler.handle(this) } + @Deprecated("Do not switch to an AsyncStoryDefinition from a synchronous story", level = DeprecationLevel.ERROR) + @ExperimentalTockCoroutines + fun handleAndSwitchStory(storyDefinition: AsyncStoryDefinition, starterIntent: Intent = storyDefinition.mainIntent()) { + handleAndSwitchStory(storyDefinition as StoryDefinition, starterIntent) + } + /** * Create one [Metric] * @param type mandatory type of [Metric] @@ -520,7 +539,7 @@ interface BotBus : Bus { */ fun i18nKey(key: String, defaultLabel: CharSequence, vararg args: Any?): I18nLabelValue = story.definition.storyHandler.let { - (it as? StoryHandlerBase<*>)?.i18nKey(key, defaultLabel, *args) + (it as? I18nStoryHandler)?.i18nKey(key, defaultLabel, *args) ?: I18nLabelValue( key, botDefinition.namespace, @@ -535,7 +554,7 @@ interface BotBus : Bus { */ fun i18nKey(key: String, defaultLabel: CharSequence, localizedDefaults: Set, vararg args: Any?): I18nLabelValue = story.definition.storyHandler.let { - (it as? StoryHandlerBase<*>)?.i18nKey(key, defaultLabel, defaultI18n = localizedDefaults, args = args) + (it as? I18nStoryHandler)?.i18nKey(key, defaultLabel, defaultI18n = localizedDefaults, args = args) ?: I18nLabelValue( key, botDefinition.namespace, diff --git a/bot/engine/src/main/kotlin/engine/BotRepository.kt b/bot/engine/src/main/kotlin/engine/BotRepository.kt index 8cfd9a3822..11779f6643 100644 --- a/bot/engine/src/main/kotlin/engine/BotRepository.kt +++ b/bot/engine/src/main/kotlin/engine/BotRepository.kt @@ -37,12 +37,11 @@ import ai.tock.bot.definition.BotProviderId import ai.tock.bot.definition.Intent import ai.tock.bot.definition.IntentAware import ai.tock.bot.definition.StoryDefinition -import ai.tock.bot.definition.StoryHandlerDefinition import ai.tock.bot.definition.StoryHandlerListener -import ai.tock.bot.definition.StoryStep +import ai.tock.bot.definition.StoryStepDef import ai.tock.bot.engine.action.ActionNotificationType -import ai.tock.bot.engine.config.BotObservabilityConfigurationMonitor import ai.tock.bot.engine.config.BotDocumentCompressorConfigurationMonitor +import ai.tock.bot.engine.config.BotObservabilityConfigurationMonitor import ai.tock.bot.engine.config.BotRAGConfigurationMonitor import ai.tock.bot.engine.config.BotVectorStoreConfigurationMonitor import ai.tock.bot.engine.config.StoryConfigurationMonitor @@ -65,14 +64,14 @@ import io.vertx.core.Deployable import io.vertx.core.DeploymentOptions import io.vertx.ext.web.Router import io.vertx.ext.web.RoutingContext -import mu.KotlinLogging -import org.litote.kmongo.Id import java.util.ServiceLoader import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.CopyOnWriteArraySet import java.util.concurrent.TimeUnit.MINUTES import java.util.concurrent.locks.Lock +import mu.KotlinLogging +import org.litote.kmongo.Id /** * Advanced bot configuration. @@ -190,7 +189,7 @@ object BotRepository { applicationId: String, recipientId: PlayerId, intent: IntentAware, - step: StoryStep? = null, + step: StoryStepDef? = null, parameters: Map = emptyMap(), stateModifier: NotifyBotStateModifier = NotifyBotStateModifier.KEEP_CURRENT_STATE, notificationType: ActionNotificationType? = null, @@ -212,7 +211,7 @@ object BotRepository { private fun ConnectorController.notifyAndCheckState( recipientId: PlayerId, intent: IntentAware, - step: StoryStep?, + step: StoryStepDef?, parameters: Map, stateModifier: NotifyBotStateModifier, notificationType: ActionNotificationType?, diff --git a/bot/engine/src/main/kotlin/engine/ConnectorController.kt b/bot/engine/src/main/kotlin/engine/ConnectorController.kt index 9ec75f884d..8fed093067 100644 --- a/bot/engine/src/main/kotlin/engine/ConnectorController.kt +++ b/bot/engine/src/main/kotlin/engine/ConnectorController.kt @@ -24,8 +24,7 @@ import ai.tock.bot.connector.ConnectorType import ai.tock.bot.definition.BotDefinition import ai.tock.bot.definition.IntentAware import ai.tock.bot.definition.StoryDefinition -import ai.tock.bot.definition.StoryHandlerDefinition -import ai.tock.bot.definition.StoryStep +import ai.tock.bot.definition.StoryStepDef import ai.tock.bot.engine.action.Action import ai.tock.bot.engine.action.ActionNotificationType import ai.tock.bot.engine.event.Event @@ -68,7 +67,7 @@ interface ConnectorController { fun notify( recipientId: PlayerId, intent: IntentAware, - step: StoryStep? = null, + step: StoryStepDef? = null, parameters: Map = emptyMap(), notificationType: ActionNotificationType?, errorListener: (Throwable) -> Unit = {} @@ -79,6 +78,8 @@ interface ConnectorController { /** * Handles an event sent by the connector. the primary goal of this controller. * + * This method may return before the event is actually processed. + * * @param event the event to handle * @param data the optional additional data from the connector */ diff --git a/bot/engine/src/main/kotlin/engine/DialogEntityAccess.kt b/bot/engine/src/main/kotlin/engine/DialogEntityAccess.kt new file mode 100644 index 0000000000..50c701a644 --- /dev/null +++ b/bot/engine/src/main/kotlin/engine/DialogEntityAccess.kt @@ -0,0 +1,110 @@ +/* + * Copyright (C) 2017/2025 SNCF Connect & Tech + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ai.tock.bot.engine + +import ai.tock.bot.engine.dialog.EntityValue +import ai.tock.nlp.api.client.model.Entity +import ai.tock.nlp.entity.Value +import kotlin.reflect.safeCast + +interface DialogEntityAccess { + /** + * Returns the current value for the specified entity role. + */ + fun entityValue( + role: String, + valueTransformer: (EntityValue) -> T? + ): T? + + /** + * Returns the current value for the specified entity. + */ + fun entityValue( + entity: Entity, + valueTransformer: (EntityValue) -> T? + ): T? = entityValue(entity.role, valueTransformer) + + /** + * Returns the current text content for the specified entity. + */ + fun entityText(entity: Entity): String? = entityValueDetails(entity)?.content + + /** + * Returns the current text content for the specified entity. + */ + fun entityText(role: String): String? = entityValueDetails(role)?.content + + /** + * Returns the current [EntityValue] for the specified entity. + */ + fun entityValueDetails(entity: Entity): EntityValue? = entityValueDetails(entity.role) + + /** + * Returns the current [EntityValue] for the specified role. + */ + fun entityValueDetails(role: String): EntityValue? + + /** + * Updates the current entity value in the dialog. + * @param role entity role + * @param newValue the new entity value + */ + fun changeEntityValue(role: String, newValue: EntityValue?) + + /** + * Updates the current entity value in the dialog. + * @param entity the entity definition + * @param newValue the new entity value + */ + fun changeEntityValue(entity: Entity, newValue: Value?) + + /** + * Updates the current entity value in the dialog. + * @param entity the entity definition + * @param newValue the new entity value + */ + fun changeEntityValue(entity: Entity, newValue: EntityValue) = changeEntityValue(entity.role, newValue) + + /** + * Updates the current entity text value in the dialog. + * @param entity the entity definition + * @param textContent the new entity text content + */ + fun changeEntityText(entity: Entity, textContent: String?) = + changeEntityValue( + entity.role, + EntityValue(entity, null, textContent) + ) + + /** + * Removes entity value for the specified role. + */ + fun removeEntityValue(role: String) = changeEntityValue(role, null) + + /** + * Removes entity value for the specified role. + */ + fun removeEntityValue(entity: Entity) = removeEntityValue(entity.role) + + /** + * Removes all current entity values. + */ + fun removeAllEntityValues() +} + +inline fun DialogEntityAccess.entityValue(role: String) = entityValue(role) { T::class.safeCast(it.value) } +inline fun DialogEntityAccess.entityValue(type: Entity) = entityValue(type) { T::class.safeCast(it.value) } diff --git a/bot/engine/src/main/kotlin/engine/TockBotBus.kt b/bot/engine/src/main/kotlin/engine/TockBotBus.kt index 9eb1c4e6c9..00f242c3fd 100644 --- a/bot/engine/src/main/kotlin/engine/TockBotBus.kt +++ b/bot/engine/src/main/kotlin/engine/TockBotBus.kt @@ -34,14 +34,25 @@ import ai.tock.bot.engine.dialog.NextUserActionState import ai.tock.bot.engine.dialog.Story import ai.tock.bot.engine.user.UserPreferences import ai.tock.bot.engine.user.UserTimeline +import ai.tock.shared.Executor +import ai.tock.shared.coroutines.ExperimentalTockCoroutines import ai.tock.shared.defaultLocale +import ai.tock.shared.injector +import ai.tock.shared.provide import ai.tock.translator.I18nKeyProvider import ai.tock.translator.UserInterfaceType import java.util.Locale +import java.util.concurrent.atomic.AtomicReference +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch /** * */ +@OptIn(ExperimentalTockCoroutines::class) internal class TockBotBus( val connector: TockConnectorController, override val userTimeline: UserTimeline, @@ -61,7 +72,7 @@ internal class TockBotBus( currentDialog.stories.add(value) } override val botDefinition: BotDefinition = bot.botDefinition - override val connectorId = action.applicationId + override val connectorId = action.connectorId override val botId = action.recipientId override val userId = action.playerId override val userPreferences: UserPreferences = userTimeline.userPreferences @@ -90,6 +101,8 @@ internal class TockBotBus( private var _currentAnswerIndex: Int = 0 override val currentAnswerIndex: Int get() = _currentAnswerIndex + private val customActionSender = AtomicReference<((Action, Long) -> Unit)?>() + private fun findSupportedLocale(locale: Locale): Locale { val supp = bot.supportedLocales return when { @@ -145,12 +158,17 @@ internal class TockBotBus( // to receive the corresponding messages if(actionToSent !is SendDebug || ConnectorType.rest == sourceConnectorType) { // If the action is not a SendDebug, or it is, but the source connector is the rest connector - connector.send(userTimeline, connectorData, action, actionToSent, context.currentDelay) + customActionSender.get()?.invoke(actionToSent, context.currentDelay) + ?: doSend(actionToSent, context.currentDelay) } return this } + fun doSend(actionToSend: Action, delay: Long) { + connector.send(userTimeline, connectorData, action, actionToSend, delay) + } + /** * Update Action using BotAnswerInterceptor */ @@ -222,4 +240,29 @@ internal class TockBotBus( bot.markAsUnknown(action, userTimeline) } } + + /** + * @return a callback to force-close the message queue + */ + fun deferMessageSending(scope: CoroutineScope): () -> Unit { + data class QueuedAction(val action: Action, val delay: Long) + + val messageChannel = Channel(Channel.BUFFERED) + customActionSender.set { action, delay -> + // we queue in the current thread to preserve message ordering + scope.launch(start = CoroutineStart.UNDISPATCHED) { + messageChannel.send(QueuedAction(action, delay)) + // the following code may happen in a different thread if the channel's buffer was full + if (action.metadata.lastAnswer) { + messageChannel.close() + } + } + } + scope.launch(injector.provide().asCoroutineDispatcher()) { + for ((action, delay) in messageChannel) { + doSend(action, delay) + } + } + return { messageChannel.close() } + } } diff --git a/bot/engine/src/main/kotlin/engine/TockConnectorController.kt b/bot/engine/src/main/kotlin/engine/TockConnectorController.kt index 77054d249f..4008b83bcb 100644 --- a/bot/engine/src/main/kotlin/engine/TockConnectorController.kt +++ b/bot/engine/src/main/kotlin/engine/TockConnectorController.kt @@ -27,8 +27,8 @@ import ai.tock.bot.engine.action.SendAttachment import ai.tock.bot.engine.action.SendAttachment.AttachmentType.audio import ai.tock.bot.engine.action.SendSentence import ai.tock.bot.engine.event.Event -import ai.tock.bot.engine.event.TypingOnEvent import ai.tock.bot.engine.event.MetadataEvent +import ai.tock.bot.engine.event.TypingOnEvent import ai.tock.bot.engine.user.PlayerId import ai.tock.bot.engine.user.UserLock import ai.tock.bot.engine.user.UserPreferences @@ -36,6 +36,8 @@ import ai.tock.bot.engine.user.UserTimeline import ai.tock.bot.engine.user.UserTimelineDAO import ai.tock.shared.Executor import ai.tock.shared.booleanProperty +import ai.tock.shared.coroutines.ExperimentalTockCoroutines +import ai.tock.shared.coroutines.launchCoroutine import ai.tock.shared.error import ai.tock.shared.injector import ai.tock.shared.intProperty @@ -44,10 +46,10 @@ import ai.tock.shared.provide import ai.tock.stt.STT import com.github.salomonbrys.kodein.instance import io.vertx.ext.web.Router -import mu.KotlinLogging import java.net.URL -import java.time.Duration import java.util.concurrent.CopyOnWriteArrayList +import kotlinx.coroutines.delay +import mu.KotlinLogging private val synchronousMode = booleanProperty("tock_timeline_persistence_synchronous_mode", true) private val asynchronousMode = booleanProperty("tock_timeline_persistence_asynchronous_mode", false) @@ -101,6 +103,16 @@ internal class TockConnectorController( fun getBaseUrl(): String = configuration.getBaseUrl() + /** + * Handles an event sent by the connector. + * + * If [event] is an [Action], the processing is done asynchronously using the shared [Executor], + * with this method returning immediately. + * + * @param event the event to handle + * @param data the optional additional data from the connector + */ + @OptIn(ExperimentalTockCoroutines::class) override fun handle(event: Event, data: ConnectorData) { if (event.state.sourceConnectorType == null) { event.state.sourceConnectorType = connector.connectorType @@ -113,7 +125,9 @@ internal class TockConnectorController( try { if (!botDefinition.eventListener.listenEvent(this, data, event)) { when (event) { - is Action -> handleAction(event, 0, data) + is Action -> executor.launchCoroutine { + handleAction(event, 0, data) + } else -> callback.eventSkipped(event) } } else { @@ -143,14 +157,15 @@ internal class TockConnectorController( return action } - private fun handleAction(action: Action, nbAttempts: Int, data: ConnectorData) { + @ExperimentalTockCoroutines + private suspend fun handleAction(action: Action, nbAttempts: Int, data: ConnectorData) { val callback = data.callback try { val playerId = action.playerId val id = playerId.id logger.debug { "try to lock $playerId" } - if (userLock.lock(id)) { + if (userLock.tryLock(id)) { try { callback.userLocked(action) @@ -165,7 +180,7 @@ internal class TockConnectorController( val transformedAction = tryToParseVoiceAudio(action, userTimeline) - bot.handle(transformedAction, userTimeline, this, data) + bot.handleAction(transformedAction, userTimeline, this, data) if (synchronousMode && data.saveTimeline) { userTimelineDAO.save(userTimeline, bot.botDefinition) @@ -179,9 +194,8 @@ internal class TockConnectorController( } } else if (nbAttempts < maxLockedAttempts) { logger.debug { "$playerId locked - wait" } - executor.executeBlocking(Duration.ofMillis(lockedAttemptsWaitInMs)) { - handleAction(action, nbAttempts + 1, data) - } + delay(lockedAttemptsWaitInMs) + handleAction(action, nbAttempts + 1, data) } else { logger.debug { "$playerId locked for $maxLockedAttempts times - skip $action" } callback.eventSkipped(action) diff --git a/bot/engine/src/main/kotlin/engine/action/SendChoice.kt b/bot/engine/src/main/kotlin/engine/action/SendChoice.kt index 1715ef1f44..154d3a8733 100644 --- a/bot/engine/src/main/kotlin/engine/action/SendChoice.kt +++ b/bot/engine/src/main/kotlin/engine/action/SendChoice.kt @@ -18,8 +18,7 @@ package ai.tock.bot.engine.action import ai.tock.bot.definition.Intent import ai.tock.bot.definition.IntentAware -import ai.tock.bot.definition.StoryHandlerDefinition -import ai.tock.bot.definition.StoryStep +import ai.tock.bot.definition.StoryStepDef import ai.tock.bot.engine.Bus import ai.tock.bot.engine.dialog.EventState import ai.tock.bot.engine.message.Choice @@ -75,7 +74,7 @@ class SendChoice( connectorId: String, recipientId: PlayerId, intentName: String, - step: StoryStep?, + step: StoryStepDef?, parameters: Map = emptyMap(), id: Id = newId(), date: Instant = Instant.now(), @@ -110,7 +109,7 @@ class SendChoice( applicationId: String, recipientId: PlayerId, intentName: String, - step: StoryStep?, + step: StoryStepDef?, parameters: Map = emptyMap(), id: Id = newId(), date: Instant = Instant.now(), @@ -169,7 +168,7 @@ class SendChoice( /** * The target step. */ - step: StoryStep? = null, + step: StoryStepDef? = null, /** * The custom parameters. */ @@ -227,7 +226,7 @@ class SendChoice( /** * The target step. */ - step: StoryStep? = null, + step: StoryStepDef? = null, /** * The custom parameters. */ @@ -235,7 +234,7 @@ class SendChoice( /** * The current step of the bus. */ - busStep: StoryStep? = null, + busStep: StoryStepDef? = null, /** * The current intent of the bus. */ diff --git a/bot/engine/src/main/kotlin/engine/config/ConfiguredStoryDefinition.kt b/bot/engine/src/main/kotlin/engine/config/ConfiguredStoryDefinition.kt index d9fe9a15f3..7ada0c020c 100644 --- a/bot/engine/src/main/kotlin/engine/config/ConfiguredStoryDefinition.kt +++ b/bot/engine/src/main/kotlin/engine/config/ConfiguredStoryDefinition.kt @@ -23,7 +23,7 @@ import ai.tock.bot.admin.story.StoryDefinitionConfigurationStep.Step import ai.tock.bot.definition.Intent import ai.tock.bot.definition.StoryDefinition import ai.tock.bot.definition.StoryHandler -import ai.tock.bot.definition.StoryStep +import ai.tock.bot.definition.StoryStepDef import ai.tock.bot.definition.StoryTag import ai.tock.translator.UserInterfaceType @@ -63,7 +63,7 @@ internal class ConfiguredStoryDefinition( override val storyHandler: StoryHandler = ConfiguredStoryHandler(definition, configuration, configurationStoryHandler) - override val steps: Set> = + override val steps: Set = (configuration.storyDefinition(definition, configuration)?.steps ?: emptySet()) + configuration.findSteps(botApplicationConfigurationKey).map { it.toStoryStep(configuration) } diff --git a/bot/engine/src/main/kotlin/engine/dialog/DialogState.kt b/bot/engine/src/main/kotlin/engine/dialog/DialogState.kt index 6211c464db..3b8b30dd6e 100644 --- a/bot/engine/src/main/kotlin/engine/dialog/DialogState.kt +++ b/bot/engine/src/main/kotlin/engine/dialog/DialogState.kt @@ -16,6 +16,7 @@ package ai.tock.bot.engine.dialog +import ai.tock.bot.definition.DialogContextKey import ai.tock.bot.definition.Intent import ai.tock.bot.engine.user.UserLocation import ai.tock.nlp.api.client.model.Entity @@ -115,6 +116,22 @@ data class DialogState( } } + /** + * Updates persistent context value. + * Do not store generic objects like Collection or Map in the context, only plain objects or typed arrays. + */ + fun setContextValue(key: DialogContextKey, value: Any?) { + require(key.type.typeParameters.isEmpty()) { + "Generic type parameters cannot be safely preserved in a dialog context" + } + + if (value == null) { + context.remove(key.name) + } else { + context[key.name] = value + } + } + /** * Set a new entity value. Remove previous entity values history. * diff --git a/bot/engine/src/main/kotlin/engine/dialog/Story.kt b/bot/engine/src/main/kotlin/engine/dialog/Story.kt index dc198c977f..04d21639e7 100644 --- a/bot/engine/src/main/kotlin/engine/dialog/Story.kt +++ b/bot/engine/src/main/kotlin/engine/dialog/Story.kt @@ -16,18 +16,21 @@ package ai.tock.bot.engine.dialog +import ai.tock.bot.definition.AsyncStoryHandler import ai.tock.bot.definition.Intent import ai.tock.bot.definition.StoryDefinition import ai.tock.bot.definition.StoryHandler -import ai.tock.bot.definition.StoryStep +import ai.tock.bot.definition.StoryStepDef import ai.tock.bot.definition.StoryTag.CHECK_ONLY_SUB_STEPS import ai.tock.bot.definition.StoryTag.CHECK_ONLY_SUB_STEPS_WITH_STORY_INTENT +import ai.tock.bot.engine.AsyncBotBus import ai.tock.bot.engine.BotBus import ai.tock.bot.engine.BotRepository import ai.tock.bot.engine.action.Action import ai.tock.bot.engine.action.SendChoice import ai.tock.bot.engine.user.PlayerType import ai.tock.bot.engine.user.UserTimeline +import ai.tock.shared.coroutines.ExperimentalTockCoroutines import ai.tock.shared.error import mu.KotlinLogging @@ -59,14 +62,14 @@ data class Story( /** * The current step of the story. */ - val currentStep: StoryStep<*>? get() = definition.steps.asSequence().mapNotNull { findStep(it) }.firstOrNull() + val currentStep: StoryStepDef? get() = definition.steps.asSequence().mapNotNull { findStep(it) }.firstOrNull() /** * True if the story handle metrics and is not a main tracked story */ val metricStory get() = definition.metricStory - private fun findStep(step: StoryStep<*>): StoryStep<*>? { + private fun findStep(step: StoryStepDef): StoryStepDef? { if (step.name == this.step) { return step } else { @@ -75,12 +78,12 @@ data class Story( } private fun findStep( - steps: Collection>, + steps: Collection, userTimeline: UserTimeline, dialog: Dialog, action: Action, intent: Intent? - ): StoryStep<*>? { + ): StoryStepDef? { // first level findStepInTree(steps, userTimeline, dialog, action, intent)?.also { return it @@ -96,12 +99,12 @@ data class Story( } private fun findStepInTree( - steps: Collection>, + steps: Collection, userTimeline: UserTimeline, dialog: Dialog, action: Action, intent: Intent? - ): StoryStep<*>? { + ): StoryStepDef? { // first level steps.forEach { s -> if (s.selectFromAction(userTimeline, dialog, action, intent)) { @@ -117,10 +120,10 @@ data class Story( return null } - private fun findParentStep(child: StoryStep<*>): StoryStep<*>? = + private fun findParentStep(child: StoryStepDef): StoryStepDef? = definition.steps.asSequence().mapNotNull { findParentStep(it, child) }.firstOrNull() - private fun findParentStep(current: StoryStep<*>, child: StoryStep<*>): StoryStep<*>? = + private fun findParentStep(current: StoryStepDef, child: StoryStepDef): StoryStepDef? = current.takeIf { current.children.any { child.name == it.name } } ?: current.children.asSequence().mapNotNull { findParentStep(it, child) }.firstOrNull() @@ -150,14 +153,39 @@ data class Story( * Handles a request. */ fun handle(bus: BotBus) { - definition.storyHandler.apply { - try { - if (sendStartEvent(bus)) { - handle(bus) - } - } finally { - sendEndEvent(bus) + val storyHandler = definition.storyHandler + @OptIn(ExperimentalTockCoroutines::class) + if (storyHandler is AsyncStoryHandler) { + // This path can only occur if this method was called from user code (TOCK always calls the suspending overload) + error("Do not call Story.handle on an async story (${definition.id}), use handle(AsyncBotBus) instead") + } else { + storyHandler.withEvents(bus) { + it.handle(bus) + } + } + } + + /** + * Handles a request using coroutines. + */ + @ExperimentalTockCoroutines + suspend fun handle(bus: AsyncBotBus) { + definition.storyHandler.withEvents(bus.botBus) { + if (it is AsyncStoryHandler) { + it.handle(bus) + } else { + it.handle(bus.botBus) + } + } + } + + private inline fun T.withEvents(bus: BotBus, op: (T) -> Unit) { + try { + if (sendStartEvent(bus)) { + op(this) } + } finally { + sendEndEvent(bus) } } @@ -238,7 +266,7 @@ data class Story( if (!forced && this.step == null) { if (s != null) { - var parent: StoryStep<*>? = s + var parent: StoryStepDef? = s do { parent = parent?.let { findParentStep(it) } parent?.children?.let { findStepInTree(it, userTimeline, dialog, action, newIntent) }?.apply { diff --git a/bot/engine/src/main/kotlin/engine/message/Choice.kt b/bot/engine/src/main/kotlin/engine/message/Choice.kt index 3c1db0a404..a0401ac62d 100644 --- a/bot/engine/src/main/kotlin/engine/message/Choice.kt +++ b/bot/engine/src/main/kotlin/engine/message/Choice.kt @@ -16,8 +16,7 @@ package ai.tock.bot.engine.message -import ai.tock.bot.definition.StoryHandlerDefinition -import ai.tock.bot.definition.StoryStep +import ai.tock.bot.definition.StoryStepDef import ai.tock.bot.engine.action.Action import ai.tock.bot.engine.action.SendChoice import ai.tock.bot.engine.event.EventType @@ -50,7 +49,7 @@ data class Choice( constructor( intentName: String, - step: StoryStep, + step: StoryStepDef, parameters: Map = emptyMap(), delay: Long = 0 ) : diff --git a/bot/engine/src/main/kotlin/engine/message/Suggestion.kt b/bot/engine/src/main/kotlin/engine/message/Suggestion.kt index 7fb1b98942..bfc625c4a3 100644 --- a/bot/engine/src/main/kotlin/engine/message/Suggestion.kt +++ b/bot/engine/src/main/kotlin/engine/message/Suggestion.kt @@ -18,16 +18,16 @@ package ai.tock.bot.engine.message import ai.tock.bot.definition.IntentAware import ai.tock.bot.definition.Parameters -import ai.tock.bot.definition.StoryStep +import ai.tock.bot.definition.StoryStepDef data class Suggestion( val title: CharSequence, val intent: IntentAware? = null, val parameters: Parameters = Parameters(), - val step: StoryStep<*>? = null, + val step: StoryStepDef? = null, val attributes: Map = emptyMap() ) { - constructor(title: CharSequence, intent: IntentAware, step: StoryStep<*>? = null, parameters: Parameters = Parameters()) : + constructor(title: CharSequence, intent: IntentAware, step: StoryStepDef? = null, parameters: Parameters = Parameters()) : this(title, intent, parameters, step) } diff --git a/bot/engine/src/main/kotlin/engine/user/UserLock.kt b/bot/engine/src/main/kotlin/engine/user/UserLock.kt index a73b198545..b5c93e4fab 100644 --- a/bot/engine/src/main/kotlin/engine/user/UserLock.kt +++ b/bot/engine/src/main/kotlin/engine/user/UserLock.kt @@ -23,5 +23,15 @@ interface UserLock { fun lock(userId: String): Boolean + /** + * Acquires the user lock only if it is free at the time of invocation + * + * Acquires the lock for the given [userId] if it is available and returns immediately + * with the value `true`. + * If the lock is not available then this method will return + * immediately with the value `false`. + */ + suspend fun tryLock(userId: String): Boolean = lock(userId) + fun releaseLock(userId: String) } diff --git a/bot/engine/src/test/kotlin/definition/AsyncBotBusTest.kt b/bot/engine/src/test/kotlin/definition/AsyncBotBusTest.kt new file mode 100644 index 0000000000..f917fe206d --- /dev/null +++ b/bot/engine/src/test/kotlin/definition/AsyncBotBusTest.kt @@ -0,0 +1,101 @@ +/* + * Copyright (C) 2017/2025 SNCF Connect & Tech + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ai.tock.bot.definition + +import ai.tock.bot.engine.AsyncBotBus +import ai.tock.shared.coroutines.ExperimentalTockCoroutines +import io.mockk.Ordering +import io.mockk.coVerify +import io.mockk.spyk +import io.mockk.verify +import java.util.concurrent.CopyOnWriteArrayList +import kotlin.test.assertEquals +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import org.junit.jupiter.api.Test + +@OptIn(ExperimentalTockCoroutines::class) +class AsyncBotBusTest : AsyncBotEngineTest() { + @Test + fun retrieveCurrentBus() = runBlocking { + val story = storyDef( + "async", + handling = ::AsyncDef, + preconditionsChecker = { + AsyncBotBus.retrieveCurrentBus()?.end("Hello") + }, + ) + + val asyncBotBus = spyk(AsyncBotBus(bus)) + + withContext(AsyncBotBus.Ref(asyncBotBus)) { + story.handle(asyncBotBus) + } + + coVerify(exactly = 1) { + asyncBotBus.end("Hello") + } + } + + @Test + fun `handleAndSwitchStory preserves structured concurrency`() = runBlocking { + val bus = spyk(bus) + val asyncBus = spyk(AsyncBotBus(bus)) + val done = CopyOnWriteArrayList() + + val sync = storyDef( + "sync1", + preconditionsChecker = { + done += "startSync" + end("Bye") + done += "endSync" + }, + ) + val async2 = storyDef( + "async2", + handling = ::AsyncDef, + preconditionsChecker = { + done += "startAsync2" + delay(100) + send("Hi") + handleAndSwitchStory(sync) + done += "endAsync2" + }, + ) + val async1 = storyDef( + "async1", + handling = ::AsyncDef, + preconditionsChecker = { + done += "startAsync1" + handleAndSwitchStory(async2) + done += "endAsync1" + }, + ) + (botDefinition.stories as MutableList).addAll(listOf(sync, async1, async2)) + + async1.handle(asyncBus) + assertEquals( + listOf("startAsync1", "startAsync2", "startSync", "endSync", "endAsync2", "endAsync1"), + done + ) + verify(ordering = Ordering.ORDERED) { + bus.send("Hi") + bus.end("Bye") + } + } +} diff --git a/bot/engine/src/test/kotlin/definition/AsyncBotEngineTest.kt b/bot/engine/src/test/kotlin/definition/AsyncBotEngineTest.kt new file mode 100644 index 0000000000..deb4f85ea4 --- /dev/null +++ b/bot/engine/src/test/kotlin/definition/AsyncBotEngineTest.kt @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2017/2025 SNCF Connect & Tech + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ai.tock.bot.definition + +import ai.tock.bot.engine.AsyncBotBus +import ai.tock.bot.engine.BotEngineTest +import ai.tock.bot.engine.TockBotBus +import ai.tock.shared.Executor +import ai.tock.shared.SimpleExecutor +import ai.tock.shared.coroutines.ExperimentalTockCoroutines +import com.github.salomonbrys.kodein.Kodein +import com.github.salomonbrys.kodein.bind +import com.github.salomonbrys.kodein.provider +import io.mockk.spyk +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.withContext + +@OptIn(ExperimentalTockCoroutines::class) +abstract class AsyncBotEngineTest(nbThreads: Int = 4) : BotEngineTest() { + val executor = spyk(SimpleExecutor(nbThreads)) + + override fun baseModule(): Kodein.Module { + return Kodein.Module { + import(super.baseModule()) + bind(overrides = true) with provider { executor } + } + } + + suspend fun AsyncStoryDefinition.handle(asyncBus: AsyncBotBus, dispatcher: CoroutineDispatcher = executor.asCoroutineDispatcher()) { + withContext(dispatcher + AsyncBotBus.Ref(asyncBus)) { + (asyncBus.botBus as TockBotBus).deferMessageSending(this) + storyHandler.handle(asyncBus) + } + } +} diff --git a/bot/engine/src/test/kotlin/definition/AsyncConfig.kt b/bot/engine/src/test/kotlin/definition/AsyncConfig.kt new file mode 100644 index 0000000000..5df86b7f1c --- /dev/null +++ b/bot/engine/src/test/kotlin/definition/AsyncConfig.kt @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2017/2025 SNCF Connect & Tech + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ai.tock.bot.definition + +import ai.tock.bot.connector.ConnectorHandler +import ai.tock.bot.engine.AsyncBus +import ai.tock.shared.coroutines.ExperimentalTockCoroutines +import kotlin.reflect.KClass + + +@ExperimentalTockCoroutines +class AsyncDef(private val bus: AsyncBus) : AsyncStoryHandling { + override suspend fun handle() { + bus.end("Hello, World!") + } +} + +@ExperimentalTockCoroutines +class AsyncConn(ctx: AsyncDefWithData) : AsyncConnectorHandlingBase(ctx) { + suspend fun askToFillValue() { + end { + // Bus-specific methods can be called here + translate("hello") + } + } +} + +@ExperimentalTockCoroutines +@TestHandler(AsyncConn::class) +class AsyncDefWithData(bus: AsyncBus, val data: StoryData) : AsyncStoryHandlingBase(bus) { + override suspend fun answer() { + when { + data.entityValue == null -> c.askToFillValue() + data.departureDate == null -> askForDepartureDate(data.entityValue) + else -> askMain() + } + } + + suspend fun askForDepartureDate(entity: String) { + end("please fill departure date for {0}", entity) + } + + suspend fun askMain() { + send("message 1") + send("message 2") + end("ok") + } +} + +@ConnectorHandler(connectorTypeId = "NONE") +@Target(AnnotationTarget.CLASS) +@MustBeDocumented +annotation class TestHandler(val value: KClass) diff --git a/bot/engine/src/test/kotlin/definition/AsyncStoryHandlerBaseTest.kt b/bot/engine/src/test/kotlin/definition/AsyncStoryHandlerBaseTest.kt new file mode 100644 index 0000000000..db67f9db05 --- /dev/null +++ b/bot/engine/src/test/kotlin/definition/AsyncStoryHandlerBaseTest.kt @@ -0,0 +1,94 @@ +/* + * Copyright (C) 2017/2025 SNCF Connect & Tech + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ai.tock.bot.definition + +import ai.tock.bot.engine.AsyncBotBus +import ai.tock.bot.engine.TockBotBus +import ai.tock.bot.engine.action.SendSentence +import ai.tock.shared.coroutines.ExperimentalTockCoroutines +import io.mockk.coEvery +import io.mockk.every +import io.mockk.spyk +import io.mockk.verify +import java.time.ZonedDateTime +import kotlin.system.measureTimeMillis +import kotlin.test.assertTrue +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Test + +@OptIn(ExperimentalTockCoroutines::class) +class AsyncStoryHandlerBaseTest : AsyncBotEngineTest() { + @Test + fun `messages are sent in sequence`() = runBlocking { + val connectorWaitTime = 100L + val totalConnectorWaitTime = connectorWaitTime * 3 + val testData = StoryData("", ZonedDateTime.now()) + + val bus = spyk(bus as TockBotBus) + every { bus.doSend(any(), any()) } answers { + Thread.sleep(connectorWaitTime) + } + val asyncBus = spyk(AsyncBotBus(bus)) + + val storyHandling = spyk(AsyncDefWithData(asyncBus, testData)) + + var handlingDuration = 0L + coEvery { storyHandling.handle() } coAnswers { + handlingDuration = measureTimeMillis { + callOriginal() + } + } + + val storyDef = storyDef( + "async", + handling = { _, _ -> storyHandling }, + preconditionsChecker = { testData }, + ) + val totalStoryTime = measureTimeMillis { + storyDef.handle(asyncBus) + } + + verify { + bus.doSend( + match { + it.text.toString() == "message 1" + }, + eq(0) + ) + bus.doSend( + match { + it.text.toString() == "message 2" + }, + eq(BotDefinition.defaultBreath) + ) + bus.doSend( + match { + it.text.toString() == "ok" + }, + eq(BotDefinition.defaultBreath * 2) + ) + } + + // Note: this check may fail when debugging breakpoints are used + assertTrue("Calls to connector.send should be concurrent to story execution") { + handlingDuration < totalConnectorWaitTime + } + assertTrue("Calls to connector.send should happen in sequence") { + totalStoryTime >= totalConnectorWaitTime + } + } +} diff --git a/bot/engine/src/test/kotlin/definition/DefaultConnectorHandlerProviderTest.kt b/bot/engine/src/test/kotlin/definition/DefaultConnectorHandlerProviderTest.kt new file mode 100644 index 0000000000..2f5260ac87 --- /dev/null +++ b/bot/engine/src/test/kotlin/definition/DefaultConnectorHandlerProviderTest.kt @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2017/2025 SNCF Connect & Tech + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ai.tock.bot.definition + +import ai.tock.bot.connector.ConnectorType +import ai.tock.bot.engine.AsyncBus +import ai.tock.bot.engine.BotBus +import ai.tock.shared.coroutines.ExperimentalTockCoroutines +import io.mockk.every +import io.mockk.mockk +import kotlin.test.assertEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertInstanceOf +import org.junit.jupiter.api.assertThrows + +@OptIn(ExperimentalTockCoroutines::class) +class DefaultConnectorHandlerProviderTest { + @Test + fun `AsyncStoryHandling creation is ok`() { + val context = AsyncDefWithData(mockk(), StoryData()) + val c = DefaultConnectorHandlerProvider.provide(context, ConnectorType.none) + assertInstanceOf(c) + assertEquals(context, c.context) + } + + @Test + fun `ConnectorStoryHandlerBase creation is ok`() { + val context = TestDef(mockk { every { targetConnectorType } returns ConnectorType.none }) + val c = DefaultConnectorHandlerProvider.provide(context, ConnectorType.none) + assertInstanceOf>(c) + assertEquals(context, c.context) + } + + @Test + fun `ConnectorStoryHandlerBase creation without a context parameter is ko`() { + val context = InvalidTestDef(mockk { every { targetConnectorType } returns ConnectorType.none }) + assertThrows { + DefaultConnectorHandlerProvider.provide(context, ConnectorType.none) + } + } + + @TestHandler(TestConnDef::class) + class TestDef(bus: BotBus) : HandlerDef>>(bus) + class TestConnDef(context: T) : ConnectorStoryHandlerBase(context) + + class InvalidTestConnDef(provider: () -> TestDef) : ConnectorStoryHandlerBase(provider()) + @TestHandler(InvalidTestConnDef::class) + class InvalidTestDef(bus: BotBus) : HandlerDef(bus) +} diff --git a/bot/engine/src/test/kotlin/definition/DefinitionBuildersTest.kt b/bot/engine/src/test/kotlin/definition/DefinitionBuildersTest.kt index a659697372..c9f14bcef9 100644 --- a/bot/engine/src/test/kotlin/definition/DefinitionBuildersTest.kt +++ b/bot/engine/src/test/kotlin/definition/DefinitionBuildersTest.kt @@ -19,9 +19,9 @@ package ai.tock.bot.definition import ai.tock.bot.engine.BotEngineTest import ai.tock.bot.engine.TestStoryDefinition import ai.tock.nlp.entity.date.DateEntityRange -import org.junit.jupiter.api.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull +import org.junit.jupiter.api.Test /** * @@ -91,4 +91,14 @@ class DefinitionBuildersTest : BotEngineTest() { } assertNotNull(s) } + + @Test + fun `async story is ok`() { + val s = storyDef( + "yah", + handling = { bus, data -> AsyncDefWithData(bus, data) }, + preconditionsChecker = { StoryData() }, + ) + assertNotNull(s) + } } diff --git a/bot/engine/src/test/kotlin/definition/StoryHandlerDefinitionBaseTest.kt b/bot/engine/src/test/kotlin/definition/StoryHandlerDefinitionBaseTest.kt index e0db9d25bc..fa3d609cc9 100644 --- a/bot/engine/src/test/kotlin/definition/StoryHandlerDefinitionBaseTest.kt +++ b/bot/engine/src/test/kotlin/definition/StoryHandlerDefinitionBaseTest.kt @@ -19,16 +19,16 @@ package definition import ai.tock.bot.connector.ConnectorHandler import ai.tock.bot.connector.ConnectorType import ai.tock.bot.definition.ConnectorDef -import ai.tock.bot.definition.ConnectorStoryHandler +import ai.tock.bot.definition.ConnectorSpecificHandling import ai.tock.bot.definition.HandlerDef import ai.tock.bot.definition.defaultHandlerStoryDefinitionCreator import ai.tock.bot.engine.BotBus import io.mockk.every import io.mockk.mockk -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.Test import kotlin.reflect.KClass import kotlin.test.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test internal class StoryHandlerDefinitionBaseTest { @@ -78,10 +78,10 @@ internal class StoryHandlerDefinitionBaseTest { @ConnectorHandler(connectorTypeId = "connectorType1") @Target(AnnotationTarget.CLASS) @MustBeDocumented - annotation class FirstConnectorHandler(val value: KClass>) + annotation class FirstConnectorHandler(val value: KClass) @ConnectorHandler(connectorTypeId = "connectorType2") @Target(AnnotationTarget.CLASS) @MustBeDocumented - annotation class SecondConnectorHandler(val value: KClass>) + annotation class SecondConnectorHandler(val value: KClass) } diff --git a/bot/engine/src/test/kotlin/engine/BotBusTest.kt b/bot/engine/src/test/kotlin/engine/BotBusTest.kt index 3dd96f9fae..6659881c40 100644 --- a/bot/engine/src/test/kotlin/engine/BotBusTest.kt +++ b/bot/engine/src/test/kotlin/engine/BotBusTest.kt @@ -16,6 +16,7 @@ package ai.tock.bot.engine +import ai.tock.bot.connector.ConnectorData import ai.tock.bot.connector.ConnectorMessage import ai.tock.bot.connector.ConnectorType import ai.tock.bot.definition.BotAnswerInterceptor @@ -32,6 +33,8 @@ import ai.tock.bot.engine.message.Choice import ai.tock.bot.engine.message.Sentence import ai.tock.bot.engine.user.PlayerId import ai.tock.bot.engine.user.UserPreferences +import ai.tock.bot.engine.user.UserTimeline +import ai.tock.shared.coroutines.ExperimentalTockCoroutines import ai.tock.translator.I18nLabelValue import io.mockk.every import io.mockk.mockk @@ -41,6 +44,7 @@ import java.util.Locale import kotlin.test.BeforeTest import kotlin.test.assertEquals import kotlin.test.assertNull +import kotlinx.coroutines.runBlocking import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test @@ -49,6 +53,12 @@ import org.junit.jupiter.api.Test * */ class BotBusTest : BotEngineTest() { + private fun Bot.handle(action: Action, userTimeline: UserTimeline, connector: ConnectorController, connectorData: ConnectorData) { + @OptIn(ExperimentalTockCoroutines::class) + runBlocking { + handleAction(action, userTimeline, connector, connectorData) + } + } @BeforeTest fun init() { diff --git a/bot/engine/src/test/kotlin/engine/BotEngineTest.kt b/bot/engine/src/test/kotlin/engine/BotEngineTest.kt index df8df11fab..800c42ea37 100644 --- a/bot/engine/src/test/kotlin/engine/BotEngineTest.kt +++ b/bot/engine/src/test/kotlin/engine/BotEngineTest.kt @@ -148,7 +148,7 @@ abstract class BotEngineTest { tockInternalInjector = KodeinInjector() injector.inject( Kodein { - import(baseModule()) + import(baseModule(), allowOverride = true) } ) diff --git a/bot/engine/src/test/kotlin/engine/BotTest.kt b/bot/engine/src/test/kotlin/engine/BotTest.kt index 907013dac5..51b2d96dfb 100644 --- a/bot/engine/src/test/kotlin/engine/BotTest.kt +++ b/bot/engine/src/test/kotlin/engine/BotTest.kt @@ -16,16 +16,20 @@ package ai.tock.bot.engine +import ai.tock.bot.connector.ConnectorData import ai.tock.bot.definition.Intent import ai.tock.bot.engine.StepTest.s4 import ai.tock.bot.engine.TestStoryDefinition.test import ai.tock.bot.engine.TestStoryDefinition.unknown +import ai.tock.bot.engine.action.Action import ai.tock.bot.engine.action.SendChoice import ai.tock.bot.engine.action.SendSentence import ai.tock.bot.engine.dialog.Dialog import ai.tock.bot.engine.dialog.Story import ai.tock.bot.engine.message.Choice import ai.tock.bot.engine.message.Sentence +import ai.tock.bot.engine.user.UserTimeline +import ai.tock.shared.coroutines.ExperimentalTockCoroutines import io.mockk.every import io.mockk.slot import io.mockk.spyk @@ -34,12 +38,19 @@ import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue +import kotlinx.coroutines.runBlocking import org.junit.jupiter.api.Test /** * */ class BotTest : BotEngineTest() { + private fun Bot.handle(action: Action, userTimeline: UserTimeline, connector: ConnectorController, connectorData: ConnectorData) { + @OptIn(ExperimentalTockCoroutines::class) + runBlocking { + handleAction(action, userTimeline, connector, connectorData) + } + } @Test fun handleSendSentence_whenNotWaitingRawInput_shouldSendNlpQuery() { diff --git a/bot/storage-mongo/src/main/kotlin/MongoUserLock.kt b/bot/storage-mongo/src/main/kotlin/MongoUserLock.kt index 6eb3702983..e62b0a9f5b 100644 --- a/bot/storage-mongo/src/main/kotlin/MongoUserLock.kt +++ b/bot/storage-mongo/src/main/kotlin/MongoUserLock.kt @@ -26,6 +26,9 @@ import ai.tock.shared.error import ai.tock.shared.longProperty import com.mongodb.MongoWriteException import com.mongodb.client.model.IndexOptions +import java.time.Instant +import java.time.Instant.now +import java.util.concurrent.TimeUnit.HOURS import mu.KotlinLogging import org.litote.jackson.data.JacksonData import org.litote.kmongo.Data @@ -41,9 +44,6 @@ import org.litote.kmongo.toId import org.litote.kmongo.updateOne import org.litote.kmongo.updateOneById import org.litote.kmongo.upsert -import java.time.Instant -import java.time.Instant.now -import java.util.concurrent.TimeUnit.HOURS /** * diff --git a/bot/storage-mongo/src/test/kotlin/MongoUserLockTest.kt b/bot/storage-mongo/src/test/kotlin/MongoUserLockTest.kt index e43a1715c0..b2419dadc8 100644 --- a/bot/storage-mongo/src/test/kotlin/MongoUserLockTest.kt +++ b/bot/storage-mongo/src/test/kotlin/MongoUserLockTest.kt @@ -16,11 +16,11 @@ package ai.tock.bot.mongo +import kotlin.test.assertFalse +import kotlin.test.assertTrue import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test -import kotlin.test.assertFalse -import kotlin.test.assertTrue /** * diff --git a/bot/test-base/src/main/kotlin/BotBusMockContext.kt b/bot/test-base/src/main/kotlin/BotBusMockContext.kt index 5eabc6da04..4e6d83948a 100644 --- a/bot/test-base/src/main/kotlin/BotBusMockContext.kt +++ b/bot/test-base/src/main/kotlin/BotBusMockContext.kt @@ -22,8 +22,7 @@ import ai.tock.bot.definition.BotDefinition import ai.tock.bot.definition.IntentAware import ai.tock.bot.definition.Parameters import ai.tock.bot.definition.StoryDefinition -import ai.tock.bot.definition.StoryHandlerDefinition -import ai.tock.bot.definition.StoryStep +import ai.tock.bot.definition.StoryStepDef import ai.tock.bot.engine.action.Action import ai.tock.bot.engine.action.SendChoice import ai.tock.bot.engine.action.SendChoice.Companion.decodeChoiceId @@ -244,7 +243,7 @@ data class BotBusMockContext( */ fun choice( intentName: String, - step: StoryStep, + step: StoryStepDef, vararg parameters: Pair ): SendChoice = SendChoice(userId, applicationId, botId, intentName, step, parameters.toMap()) @@ -253,7 +252,7 @@ data class BotBusMockContext( */ fun choice( intent: IntentAware, - step: StoryStep, + step: StoryStepDef, parameters: Parameters ): SendChoice = SendChoice(userId, applicationId, botId, intent.wrappedIntent().name, step, parameters.toMap()) diff --git a/bot/test-base/src/main/kotlin/mock/BotBusMocked.kt b/bot/test-base/src/main/kotlin/mock/BotBusMocked.kt index a990cfc7c5..03fcb1780f 100644 --- a/bot/test-base/src/main/kotlin/mock/BotBusMocked.kt +++ b/bot/test-base/src/main/kotlin/mock/BotBusMocked.kt @@ -27,8 +27,7 @@ import ai.tock.bot.definition.BotDefinition import ai.tock.bot.definition.IntentAware import ai.tock.bot.definition.StoryDefinitionBase import ai.tock.bot.definition.StoryHandlerBase -import ai.tock.bot.definition.StoryHandlerDefinition -import ai.tock.bot.definition.StoryStep +import ai.tock.bot.definition.StoryStepDef import ai.tock.bot.engine.BotBus import ai.tock.bot.engine.action.Action import ai.tock.bot.engine.action.ActionQuote @@ -184,13 +183,13 @@ fun provideMockedBusCommon(bus: BotBus = mockk()): BotBus { mockkObject(SendChoice.Companion) every { - SendChoice.encodeChoiceId(bus, any(), any>(), any()) + SendChoice.encodeChoiceId(bus, any(), any(), any()) } answers { @Suppress("UNCHECKED_CAST") SendChoice.encodeChoiceId( - (args[1] as IntentAware).wrappedIntent(), - args[2] as? StoryStep, - (args[3] as? Map) ?: emptyMap(), + arg(1).wrappedIntent(), + arg(2), + arg>(3), null, null, bus.connectorId diff --git a/docs/docs/en/dev/advanced/coroutine-stories.md b/docs/docs/en/dev/advanced/coroutine-stories.md new file mode 100644 index 0000000000..b3466f3f7d --- /dev/null +++ b/docs/docs/en/dev/advanced/coroutine-stories.md @@ -0,0 +1,215 @@ +# Using Kotlin Coroutines in TOCK Stories + +!!! warning "Experimental Feature" + The TOCK Coroutines API is experimental. It can contain bugs, and may change in a breaking fashion without notice. + +Prerequisite reading : [Bot intégré](../bot-integre.md) + +Classes belonging to the TOCK Coroutines API are prefixed with `Async`. We find a class hierarchy very similar to the one +in the traditional integrated API mode : + +```mermaid +classDiagram + class StoryDefinition { + <> + +StoryHandler storyHandler + +Set[StoryStep] steps + } + class AsyncStoryDefinition { + <> + +AsyncStoryHandler storyHandler + +Set[AsyncStoryStep] steps + } + class StoryHandler { + <> + } + class AsyncStoryHandler { + <> + } + class AsyncStoryHandlerBase { + <> + } + class AsyncDelegatingStoryHandlerBase { + <> + } + class AsyncConfigurableStoryHandler + class StoryStepDef { + <> + } + class StoryStep { + <> + } + class AsyncStoryStep { + <> + } + class AsyncStoryHandling { + <> + } + class AsyncStoryHandlingBase { + +AsyncConnectorHandling connector + } + class AsyncConnectorHandling { + <> + } + class AsyncConnectorHandlingBase + class AsyncBus + + StoryDefinition <|.. AsyncStoryDefinition + StoryDefinition o-- StoryHandler + StoryDefinition o-- StoryStepDef + AsyncStoryDefinition o-- AsyncStoryHandler + AsyncStoryDefinition o-- AsyncStoryStep + AsyncStoryHandler <|.. StoryHandler + AsyncStoryHandler --> AsyncStoryHandling + AsyncStoryHandlerBase ..|> AsyncStoryHandler + AsyncDelegatingStoryHandlerBase ..|> AsyncStoryHandlerBase + AsyncConfigurableStoryHandler --|> AsyncDelegatingStoryHandlerBase + StoryStep <|.. StoryStepDef + AsyncStoryStep <|.. StoryStepDef + AsyncStoryHandlingBase ..|> AsyncStoryHandling + AsyncStoryHandlingBase ..|> AsyncBus + AsyncStoryHandlingBase o-- AsyncConnectorHandling + AsyncConnectorHandlingBase ..|> AsyncConnectorHandling + AsyncConnectorHandlingBase ..|> AsyncBus +``` + +Of most relevance to us are the following classes: + +- `AsyncStoryDefinition`: `StoryDefinition` specialized for `AsyncStoryHandler` and `AsyncStoryStep` +- `AsyncBus`: equivalent to `Bus` +- `AsyncStoryHandlingBase` : equivalent to `StoryHandlerDefinitionBase` +- `AsyncConnectorHandlingBase` : equivalent to `ConnectorStoryHandlerBase` +- `AsyncStoryStep` : equivalent to `StoryStep` + +Here is the example given for the integrated API bot, adapted for this new API, and with a new "round-trip display" feature +(so that we can showcase parallel calls): + +```kotlin +// The data required by the story to execute +private data class SearchArgs( + val destination: Place, + val origin: Place, + val date: LocalDateTime, + val returnDate: LocalDateTime?, +) + +/** + * This story takes an origin, a destination, a date and an optional return date and displays the first available itinerary + */ +// the story definition, specifying the primary and secondary intents, a handling class, and a precondition checker +val search = storyDef( + "search", // Main starter intent and story identifier + setOf(indicate_origin), // Other starter intent (starts the story if detected) + setOf(indicate_location), // Secondary intent (keeps the story running if detected while in the story) + handling = ::SearchHandling, // Constructor reference for your AsyncStoryHandling implementation +) { /* Precondition checker */ + // This code assumes that location, destination, origin, departureDate and returnDate are extension properties + // on the AsyncBus + + // if the current intent is indicate_location and the location entity has a value, store the value + // in the appropriate variable + if (matchesIntent(indicate_location) && location != null) { + if (destination == null || origin != null) { + destination = returnsAndRemoveLocation() + } else { + origin = returnsAndRemoveLocation() + } + } + + //check mandatory data + when { + destination == null -> end("For what destination?") + origin == null -> end("For what origin?") + departureDate == null -> end("When would you like to leave?") + } + + // args passed to the main handling class + SearchArgs(destination, origin, departureDate, returnDate) +} + +// Specify dedicated handling for two connectors via the following annotations. +// Your story can support as many or as few connectors as you want, among all those supported by the TOCK framework. +@GAHandler(GASearchConnector::class) // Connector-specific handling for Google Assistant +@MessengerHandler(MessengerSearchConnector::class) // Connector-specific handling for Facebook Messenger +class SearchHandling(bus: AsyncBus, private val args: SearchArgs) : AsyncStoryHandlingBase(bus) { + + override suspend fun answer() { + // extract the args passed by the precondition checker + val (d, o, date, returnDate) = args + + // First text message, with string interpolation + send("From {0} to {1}", o, d) + // Second text message, with string interpolation using a custom DateTimeFormatter + send("Departure on {0}", date by datetimeFormat) + // If the current connector supports it, the two messages above will be sent immediately, without waiting + // for the API call below to complete. + // This serves as a confirmation that the bot is processing the request before it can show the result. + + // Query in parallel our API for the journeys and the return journeys + val (journeys, journeysBack) = coroutineScope { + // First async call + val journeys = async { SncfOpenDataClient.journey(o, d, date) } + // Second async call, optional + val journeysBack = returnDate?.let { async { SncfOpenDataClient.journey(d, o, it) } } + // Wait for both calls to be finished + Pair(journeys.await(), journeysBack?.await()) + } + + if (journeys.isEmpty()) { + end("Désolé, aucun itinéraire trouvé :(") + } else { + send("Voici la première proposition :") + // The calls to `c` below will fail with an NPE if you didn't specify an implementation for the current connector + // To be null-safe, you can use the `connector` field instead + if (journeysBack.isNullOrEmpty()) { + // Not a round trip -> end with the one-way journey + c.endWithJourney(journeys.first()) + } else { + // Round-trip -> send both + c.sendJourney(journeys.first()) + send("Voici le voyage retour :") + c.endWithJourney(journeysBack.first()) + } + } + } +} + +/** Handles connector-specific code (rich message creation) */ +sealed class SearchConnector(context: SearchHandling) : AsyncConnectorHandlingBase(context) { + + // Utility method used in subclasses + protected fun Section.title(): CharSequence = i18n("{0} - {1}", from, to) + + // Method used by the story + fun sendJourney(journey: Journey) = send { + journey(journey.publicTransportSections()) + } + + // Method used by the story + fun endWithJourney(journey: Journey) = end { + journey(journey.publicTransportSections()) + } + + // Implementation that generates a rich display message for a given list of sections + protected abstract fun Bus<*>.journey(sections: List
): ConnectorMessage + +} + +/** Implementation of our connector for the Messenger platform. The GA variant is left as an exercise to the reader. */ +class MessengerSearchConnector(context: SearchDef) : SearchConnector(context) { + + override fun Bus<*>.journey(sections: List
): ConnectorMessage = + // Display our journey as a carousel + genericTemplate( + sections.map { section -> + with(section) { + genericElement( + title(), + content(), + trainImage + ) + } + } + ) +} +``` diff --git a/docs/docs/en/dev/bot-integre.md b/docs/docs/en/dev/bot-integre.md index bc7b1e0be4..81df2a55b7 100644 --- a/docs/docs/en/dev/bot-integre.md +++ b/docs/docs/en/dev/bot-integre.md @@ -226,13 +226,13 @@ For example, here is the code used to retrieve the `destination` entity: val destinationEntity = openBot.entity("location", "destination") -var BotBus.destination: Place? +var DialogEntityAccess.destination: Place? get() = place(destinationEntity) set(value) = setPlace(destinationEntity, value) -private fun BotBus.place(entity: Entity): Place? = entityValue(entity, ::placeValue)?.place +private fun DialogEntityAccess.place(entity: Entity): Place? = entityValue(entity, ::placeValue)?.place -private fun BotBus.setPlace(entity: Entity, place: Place?) = changeEntityValue(entity, place?.let { PlaceValue(place) }) +private fun DialogEntityAccess.setPlace(entity: Entity, place: Place?) = changeEntityValue(entity, place?.let { PlaceValue(place) }) ``` diff --git a/docs/docs/fr/dev/advanced/coroutine-stories.md b/docs/docs/fr/dev/advanced/coroutine-stories.md new file mode 100644 index 0000000000..1003c0136f --- /dev/null +++ b/docs/docs/fr/dev/advanced/coroutine-stories.md @@ -0,0 +1,215 @@ +# Utilisation des coroutines dans les stories + +!!! warning "Fonctionnalité expérimentale" + L'API TOCK Coroutines est expérimentale. Elle peut contenir des bugs, ou changer (*breaking changes*) sans préavis. + +Prérequis de lecture : [Bot intégré](../bot-integre.md) + +Les classes relevant de l'API Coroutines de TOCK sont préfixées par `Async`. On retrouve une hiérarchie de classes +très similaire à celles de l'API Bot intégré : + +```mermaid +classDiagram + class StoryDefinition { + <> + +StoryHandler storyHandler + +Set[StoryStep] steps + } + class AsyncStoryDefinition { + <> + +AsyncStoryHandler storyHandler + +Set[AsyncStoryStep] steps + } + class StoryHandler { + <> + } + class AsyncStoryHandler { + <> + } + class AsyncStoryHandlerBase { + <> + } + class AsyncDelegatingStoryHandlerBase { + <> + } + class AsyncConfigurableStoryHandler + class StoryStepDef { + <> + } + class StoryStep { + <> + } + class AsyncStoryStep { + <> + } + class AsyncStoryHandling { + <> + } + class AsyncStoryHandlingBase { + +AsyncConnectorHandling connector + } + class AsyncConnectorHandling { + <> + } + class AsyncConnectorHandlingBase + class AsyncBus + + StoryDefinition <|.. AsyncStoryDefinition + StoryDefinition o-- StoryHandler + StoryDefinition o-- StoryStepDef + AsyncStoryDefinition o-- AsyncStoryHandler + AsyncStoryDefinition o-- AsyncStoryStep + AsyncStoryHandler <|.. StoryHandler + AsyncStoryHandler --> AsyncStoryHandling + AsyncStoryHandlerBase ..|> AsyncStoryHandler + AsyncDelegatingStoryHandlerBase ..|> AsyncStoryHandlerBase + AsyncConfigurableStoryHandler --|> AsyncDelegatingStoryHandlerBase + StoryStep <|.. StoryStepDef + AsyncStoryStep <|.. StoryStepDef + AsyncStoryHandlingBase ..|> AsyncStoryHandling + AsyncStoryHandlingBase ..|> AsyncBus + AsyncStoryHandlingBase o-- AsyncConnectorHandling + AsyncConnectorHandlingBase ..|> AsyncConnectorHandling + AsyncConnectorHandlingBase ..|> AsyncBus +``` + +Les classes qui nous intéressent particulièrement sont les suivantes : + +- `AsyncStoryDefinition` : `StoryDefinition` spécialisée pour `AsyncStoryHandler` et `AsyncStoryStep` +- `AsyncBus` : équivalent de `Bus` +- `AsyncStoryHandlingBase` : équivalent de `StoryHandlerDefinitionBase` +- `AsyncConnectorHandlingBase` : équivalent de `ConnectorStoryHandlerBase` +- `AsyncStoryStep` : équivalent de `StoryStep` + +Voici l'exemple donné pour le bot intégré, adapté pour cette nouvelle API, et avec une nouvelle fonctionnalité +d'affichage des trajets retour (pour démontrer les appels en parallèle) : + +```kotlin +// The data required by the story to execute +private data class SearchArgs( + val destination: Place, + val origin: Place, + val date: LocalDateTime, + val returnDate: LocalDateTime?, +) + +/** + * This story takes an origin, a destination, a date and an optional return date and displays the first available itinerary + */ +// the story definition, specifying the primary and secondary intents, a handling class, and a precondition checker +val search = storyDef( + "search", // Main starter intent and story identifier + setOf(indicate_origin), // Other starter intent (starts the story if detected) + setOf(indicate_location), // Secondary intent (keeps the story running if detected while in the story) + handling = ::SearchHandling, // Constructor reference for your AsyncStoryHandling implementation +) { /* Precondition checker */ + // This code assumes that location, destination, origin, departureDate and returnDate are extension properties + // on the AsyncBus + + // if the current intent is indicate_location and the location entity has a value, store the value + // in the appropriate variable + if (matchesIntent(indicate_location) && location != null) { + if (destination == null || origin != null) { + destination = returnsAndRemoveLocation() + } else { + origin = returnsAndRemoveLocation() + } + } + + //check mandatory entities + when { + destination == null -> end("Pour quelle destination?") + origin == null -> end("Pour quelle origine?") + departureDate == null -> end("Quand souhaitez-vous partir?") + } + + // args passed to the main handling class + SearchArgs(destination, origin, departureDate, returnDate) +} + +// Specify dedicated handling for two connectors via the following annotations. +// Your story can support as many or as few connectors as you want, among all those supported by the TOCK framework. +@GAHandler(GASearchConnector::class) // Connector-specific handling for Google Assistant +@MessengerHandler(MessengerSearchConnector::class) // Connector-specific handling for Facebook Messenger +class SearchHandling(bus: AsyncBus, private val args: SearchArgs) : AsyncStoryHandlingBase(bus) { + + override suspend fun answer() { + // extract the args passed by the precondition checker + val (d, o, date, returnDate) = args + + // First text message, with string interpolation + send("De {0} à {1}", o, d) + // Second text message, with string interpolation using a custom DateTimeFormatter + send("Départ le {0}", date by datetimeFormat) + // If the current connector supports it, the two messages above will be sent immediately, without waiting + // for the API call below to complete. + // This serves as a confirmation that the bot is processing the request before it can show the result. + + // Query in parallel our API for the journeys and the return journeys + val (journeys, journeysBack) = coroutineScope { + // First async call + val journeys = async { SncfOpenDataClient.journey(o, d, date) } + // Second async call, optional + val journeysBack = returnDate?.let { async { SncfOpenDataClient.journey(d, o, it) } } + // Wait for both calls to be finished + Pair(journeys.await(), journeysBack?.await()) + } + + if (journeys.isEmpty()) { + end("Désolé, aucun itinéraire trouvé :(") + } else { + send("Voici la première proposition :") + // The calls to `c` below will fail with an NPE if you didn't specify an implementation for the current connector + // To be null-safe, you can use the `connector` field instead + if (journeysBack.isNullOrEmpty()) { + // Not a round trip -> end with the one-way journey + c.endWithJourney(journeys.first()) + } else { + // Round-trip -> send both + c.sendJourney(journeys.first()) + send("Voici le voyage retour :") + c.endWithJourney(journeysBack.first()) + } + } + } +} + +/** Handles connector-specific code (rich message creation) */ +sealed class SearchConnector(context: SearchHandling) : AsyncConnectorHandlingBase(context) { + + // Utility method used in subclasses + protected fun Section.title(): CharSequence = i18n("{0} - {1}", from, to) + + // Method used by the story + fun sendJourney(journey: Journey) = send { + journey(journey.publicTransportSections()) + } + + // Method used by the story + fun endWithJourney(journey: Journey) = end { + journey(journey.publicTransportSections()) + } + + // Implementation that generates a rich display message for a given list of sections + protected abstract fun Bus<*>.journey(sections: List
): ConnectorMessage + +} + +/** Implementation of our connector for the Messenger platform. The GA variant is left as an exercise to the reader. */ +class MessengerSearchConnector(context: SearchDef) : SearchConnector(context) { + + override fun Bus<*>.journey(sections: List
): ConnectorMessage = + // Display our journey as a carousel + genericTemplate( + sections.map { section -> + with(section) { + genericElement( + title(), + content(), + trainImage + ) + } + } + ) +} +``` diff --git a/docs/docs/fr/dev/bot-integre.md b/docs/docs/fr/dev/bot-integre.md index 719c07c25d..70b0c49549 100644 --- a/docs/docs/fr/dev/bot-integre.md +++ b/docs/docs/fr/dev/bot-integre.md @@ -227,13 +227,13 @@ Par exemple voici le code utilisé pour récupérer l'entité `destination` : val destinationEntity = openBot.entity("location", "destination") -var BotBus.destination: Place? +var DialogEntityAccess.destination: Place? get() = place(destinationEntity) set(value) = setPlace(destinationEntity, value) -private fun BotBus.place(entity: Entity): Place? = entityValue(entity, ::placeValue)?.place +private fun DialogEntityAccess.place(entity: Entity): Place? = entityValue(entity, ::placeValue)?.place -private fun BotBus.setPlace(entity: Entity, place: Place?) = changeEntityValue(entity, place?.let { PlaceValue(place) }) +private fun DialogEntityAccess.setPlace(entity: Entity, place: Place?) = changeEntityValue(entity, place?.let { PlaceValue(place) }) ``` diff --git a/shared/src/main/kotlin/InternalTockApi.kt b/shared/src/main/kotlin/InternalTockApi.kt new file mode 100644 index 0000000000..eb1c8b79d3 --- /dev/null +++ b/shared/src/main/kotlin/InternalTockApi.kt @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2017/2025 SNCF Connect & Tech + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ai.tock.shared + +/** + * Public API marked with this annotation is effectively **internal**, which means + * it should not be used outside TOCK main source code. + * Signature, semantics, source and binary compatibilities are not guaranteed for this API + * and will be changed without any warnings or migration aids. + * If you cannot avoid using internal API to solve your problem, please report your use-case to TOCK's issue tracker. + */ +@MustBeDocumented +@RequiresOptIn(level = RequiresOptIn.Level.ERROR) +@Retention(AnnotationRetention.BINARY) +annotation class InternalTockApi diff --git a/shared/src/main/kotlin/coroutines/ExperimentalTockCoroutines.kt b/shared/src/main/kotlin/coroutines/ExperimentalTockCoroutines.kt new file mode 100644 index 0000000000..61b20566f2 --- /dev/null +++ b/shared/src/main/kotlin/coroutines/ExperimentalTockCoroutines.kt @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2017/2025 SNCF Connect & Tech + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ai.tock.shared.coroutines + +import kotlin.RequiresOptIn.Level.WARNING + +/** + * The experimental TOCK Coroutine API marker. + * + * *Implementation note:* TOCK coroutines are executed by default in a Vert.x Worker thread. + * + * Any usage of a declaration annotated with `@ExperimentalTockCoroutines` must be accepted either by + * annotating that usage with the [OptIn] annotation, e.g. `@OptIn(ExperimentalTockCoroutines::class)`, + * or by using the compiler argument `-opt-in=ai.tock.shared.coroutines.ExperimentalTockCoroutines`. + */ +@RequiresOptIn(level = WARNING) +@MustBeDocumented +@Retention(AnnotationRetention.BINARY) +annotation class ExperimentalTockCoroutines diff --git a/shared/src/main/kotlin/coroutines/TockCoroutines.kt b/shared/src/main/kotlin/coroutines/TockCoroutines.kt new file mode 100644 index 0000000000..7c3ec90e88 --- /dev/null +++ b/shared/src/main/kotlin/coroutines/TockCoroutines.kt @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2017/2025 SNCF Connect & Tech + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ai.tock.shared.coroutines + +import ai.tock.shared.Executor +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Job +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch + +/** + * Launches a new coroutine without blocking the current thread and returns a reference to the coroutine as a [Job]. + * The coroutine is cancelled when the resulting job is [cancelled][Job.cancel]. + * + * The coroutine and its suspensions will be dispatched using this executor. + * + * @see CoroutineScope.launch + */ +@ExperimentalTockCoroutines +fun Executor.launchCoroutine( + context: CoroutineContext = EmptyCoroutineContext, + start: CoroutineStart = CoroutineStart.DEFAULT, + block: suspend CoroutineScope.() -> Unit +): Job { + return CoroutineScope(asCoroutineDispatcher()).launch(context, start, block) +} diff --git a/shared/src/test/kotlin/SharedTestModule.kt b/shared/src/test/kotlin/SharedTestModule.kt index 23152f7b0f..4e3b0ddc0b 100644 --- a/shared/src/test/kotlin/SharedTestModule.kt +++ b/shared/src/test/kotlin/SharedTestModule.kt @@ -31,15 +31,17 @@ import io.mockk.clearMocks import io.mockk.every import io.mockk.mockk import io.vertx.core.Vertx -import mu.KotlinLogging -import org.litote.kmongo.Id -import org.litote.kmongo.KFlapdoodle -import org.litote.kmongo.reactivestreams.KFlapdoodleReactiveStreams import java.time.Duration import java.util.concurrent.Callable import java.util.concurrent.CompletableFuture import java.util.concurrent.Executors +import java.util.concurrent.ThreadFactory import java.util.concurrent.TimeUnit.MILLISECONDS +import java.util.concurrent.atomic.AtomicInteger +import mu.KotlinLogging +import org.litote.kmongo.Id +import org.litote.kmongo.KFlapdoodle +import org.litote.kmongo.reactivestreams.KFlapdoodleReactiveStreams private val logger = KotlinLogging.logger {} @@ -130,9 +132,22 @@ private object NoOpCache : TockCache { /** * A simple executor that uses [nbThreads] - useful for concurrency tests. */ -class SimpleExecutor(private val nbThreads: Int) : Executor { +class SimpleExecutor(private val nbThreads: Int, private val threadPoolName: String? = "test-pool-${threadPoolId.getAndIncrement()}") : Executor { + companion object { + private val threadPoolId = AtomicInteger(1) + } - private val executor = Executors.newScheduledThreadPool(nbThreads) + private val executor = Executors.newScheduledThreadPool(nbThreads, object : ThreadFactory { + private val group = Thread.currentThread().threadGroup + private val threadNumber = AtomicInteger(1) + + override fun newThread(r: Runnable): Thread { + return Thread(group, r, "${threadPoolName}-thread-${threadNumber.getAndIncrement()}").apply { + if (isDaemon) setDaemon(false) + if (priority != Thread.NORM_PRIORITY) setPriority(Thread.NORM_PRIORITY) + } + } + }) override fun executeBlocking(delay: Duration, runnable: () -> Unit) { executor.schedule(runnable, delay.toMillis(), MILLISECONDS) diff --git a/shared/src/test/kotlin/coroutines/TockCoroutinesTest.kt b/shared/src/test/kotlin/coroutines/TockCoroutinesTest.kt new file mode 100644 index 0000000000..466a21fd02 --- /dev/null +++ b/shared/src/test/kotlin/coroutines/TockCoroutinesTest.kt @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2017/2025 SNCF Connect & Tech + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ai.tock.shared.coroutines + +import ai.tock.shared.SimpleExecutor +import io.mockk.spyk +import io.mockk.verify +import java.time.Duration +import java.time.Instant +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking + +@OptIn(ExperimentalTockCoroutines::class) +class TockCoroutinesTest { + private val threadPoolName = "tock-coroutines-test-pool" + private val executor = spyk(SimpleExecutor(10, threadPoolName)) + + @Test + fun `correctly dispatch coroutine jobs via executor`() { + var timestamp1: Instant? = null + var timestamp2: Instant? = null + val threadNames = mutableListOf() + runBlocking { + executor.launchCoroutine { + threadNames += Thread.currentThread().name + launch { + threadNames += Thread.currentThread().name + delay(1000) + threadNames += Thread.currentThread().name + timestamp1 = Instant.now() + } + timestamp2 = Instant.now() + }.join() + } + assertNotNull(timestamp1) + assertNotNull(timestamp2) + assertTrue { Duration.between(timestamp2, timestamp1) >= Duration.ofSeconds(1) } + // 3 tasks should have been executed: launchCoroutine, launch, and the instructions after delay + verify(exactly = 3) { executor.execute(any()) } + for (threadName in threadNames) { + assertEquals( + threadPoolName, + threadName.take(threadPoolName.length), + "Expected thread $threadName to be part of test threadpool" + ) + } + } +} diff --git a/translator/core/src/main/kotlin/I18nLabelValue.kt b/translator/core/src/main/kotlin/I18nLabelValue.kt index 7a00f517b4..5db34afb30 100644 --- a/translator/core/src/main/kotlin/I18nLabelValue.kt +++ b/translator/core/src/main/kotlin/I18nLabelValue.kt @@ -81,6 +81,12 @@ class I18nLabelValue constructor( fun withArgs(newArgs: List): I18nLabelValue = I18nLabelValue(key, namespace, category, defaultLabel, newArgs, defaultI18n) + /** + * Returns the value with the given args. + */ + fun withArgs(vararg newArgs: Any?): I18nLabelValue = + withArgs(listOf(*newArgs)) + override fun toString(): String { return defaultLabel.toString() } diff --git a/translator/core/src/main/kotlin/RawString.kt b/translator/core/src/main/kotlin/RawString.kt index 7d64b9fd2d..13c5a0917f 100644 --- a/translator/core/src/main/kotlin/RawString.kt +++ b/translator/core/src/main/kotlin/RawString.kt @@ -25,6 +25,8 @@ val EMPTY_TRANSLATED_STRING: TranslatedSequence = RawString("") /** * A raw string is a string that should not be translated. + * + * @see CharSequence.raw */ data class RawString(private val wrapped: CharSequence) : CharSequence by wrapped, TranslatedSequence {