diff --git a/bot/connector-web/README.md b/bot/connector-web/README.md index a553e1cb38..cf4644591a 100644 --- a/bot/connector-web/README.md +++ b/bot/connector-web/README.md @@ -156,6 +156,15 @@ and attempts to send them if and when the SSE connection is re-established. The `tock_web_sse_keepalive_delay` optional property can be used to configure the number of seconds between two SSE pings (default: 10). +#### Push messages + +When SSE is enabled, the web connector allows sending push messages through the +[`notify` method](https://github.com/theopenconversationkit/tock/blob/master/bot/engine/src/main/kotlin/definition/DefinitionBuilders.kt). + +Note that unlike with messaging apps, there is absolutely no guarantee that a user receives the message, +as they may have closed their browser since the last interaction. If they reopen the corresponding browsing tab, +they may still receive the message thanks to the aforementioned retry mechanism. + ### React chat widget The [`tock-react-kit`](https://github.com/theopenconversationkit/tock-react-kit) component provides integration with diff --git a/bot/connector-web/src/main/kotlin/WebConnector.kt b/bot/connector-web/src/main/kotlin/WebConnector.kt index 8692986648..3c8b7dbd0b 100644 --- a/bot/connector-web/src/main/kotlin/WebConnector.kt +++ b/bot/connector-web/src/main/kotlin/WebConnector.kt @@ -31,10 +31,15 @@ import ai.tock.bot.connector.web.send.PostbackButton 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.engine.BotBus import ai.tock.bot.engine.BotRepository import ai.tock.bot.engine.ConnectorController import ai.tock.bot.engine.action.Action +import ai.tock.bot.engine.action.ActionNotificationType +import ai.tock.bot.engine.action.SendChoice import ai.tock.bot.engine.event.Event import ai.tock.bot.engine.event.MetadataEvent import ai.tock.bot.engine.user.PlayerId @@ -52,6 +57,7 @@ import ai.tock.bot.orchestration.shared.SecondaryBotEligibilityResponse import ai.tock.shared.Dice import ai.tock.shared.Executor import ai.tock.shared.booleanProperty +import ai.tock.shared.defaultLocale import ai.tock.shared.injector import ai.tock.shared.jackson.mapper import ai.tock.shared.listProperty @@ -71,6 +77,7 @@ import io.vertx.core.json.JsonObject import io.vertx.ext.web.RoutingContext import io.vertx.ext.web.handler.CorsHandler import java.time.Duration +import java.util.Locale import java.util.UUID import mu.KotlinLogging @@ -262,25 +269,7 @@ class WebConnector internal constructor( val event = request.toEvent(applicationId) val requestInfos = WebRequestInfos(context.request()) WebRequestInfosByEvent.put(event.id.toString(), requestInfos) - val callback = WebConnectorCallback( - applicationId = applicationId, - locale = request.locale, - context = context, - webMapper = webMapper, - eventId = event.id.toString(), - messageProcessor = messageProcessor, - ) - if (sseEnabled) { - // Uniquely identify each response, so they can be reconciliated between SSE and POST - callback.addMetadata(MetadataEvent.responseId(UUID.randomUUID(), applicationId)) - } - controller.handle( - event, - ConnectorData( - callback = callback, - metadata = extraHeadersAsMetadata(requestInfos) - ) - ) + handleEvent(applicationId, request.locale, event, controller, context, extraHeadersAsMetadata(requestInfos)) } catch (t: Throwable) { BotRepository.requestTimer.throwable(t, timerData) context.fail(t) @@ -289,6 +278,64 @@ class WebConnector internal constructor( } } + private fun handleEvent( + applicationId: String, + locale: Locale, + event: Event, + controller: ConnectorController, + context: RoutingContext?, + headersMetadata: Map, + ) { + val callback = WebConnectorCallback( + applicationId = applicationId, + locale = locale, + context = context, + webMapper = webMapper, + eventId = event.id.toString(), + messageProcessor = messageProcessor, + ) + if (sseEnabled) { + // Uniquely identify each response, so they can be reconciliated between SSE and POST + callback.addMetadata(MetadataEvent.responseId(UUID.randomUUID(), applicationId)) + } + controller.handle( + event, + ConnectorData( + callback = callback, + metadata = headersMetadata + ) + ) + } + + override fun notify( + controller: ConnectorController, + recipientId: PlayerId, + intent: IntentAware, + step: StoryStep?, + parameters: Map, + notificationType: ActionNotificationType?, + errorListener: (Throwable) -> Unit + ) { + if (!sseEnabled) { + throw UnsupportedOperationException("Web Connector only supports notifications when SSE is enabled") + } + handleEvent( + applicationId = applicationId, + locale = defaultLocale, + event = SendChoice( + recipientId, + applicationId, + PlayerId(applicationId, bot), + intent.wrappedIntent().name, + step, + parameters + ), + controller = controller, + context = null, + headersMetadata = emptyMap(), + ) + } + /** * add extra configured Header to Metadata * accessible if "tock_web_connector_use_extra_header_as_metadata_request" is true diff --git a/bot/connector-web/src/main/kotlin/WebConnectorCallback.kt b/bot/connector-web/src/main/kotlin/WebConnectorCallback.kt index bc2b2abe31..ef384cd194 100644 --- a/bot/connector-web/src/main/kotlin/WebConnectorCallback.kt +++ b/bot/connector-web/src/main/kotlin/WebConnectorCallback.kt @@ -28,7 +28,7 @@ import java.util.concurrent.CopyOnWriteArrayList internal class WebConnectorCallback( applicationId: String, val locale: Locale, - private val context: RoutingContext, + private val context: RoutingContext?, private val actions: MutableList = CopyOnWriteArrayList(), private val metadata: MutableMap = mutableMapOf(), private val webMapper: ObjectMapper, @@ -51,8 +51,8 @@ internal class WebConnectorCallback( fun sendResponse() { WebRequestInfosByEvent.invalidate(eventId) - context.response() - .putHeader(HttpHeaders.CONTENT_TYPE, "application/json") - .end(webMapper.writeValueAsString(createResponse(actions))) + context?.response() + ?.putHeader(HttpHeaders.CONTENT_TYPE, "application/json") + ?.end(webMapper.writeValueAsString(createResponse(actions))) } }