All Downloads are FREE. Search and download functionalities are using the official Maven repository.

engine.BotRepository.kt Maven / Gradle / Ivy

/*
 * Copyright (C) 2017/2021 e-voyageurs technologies
 *
 * 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.admin.bot.BotApplicationConfiguration
import ai.tock.bot.admin.bot.BotApplicationConfigurationDAO
import ai.tock.bot.admin.bot.BotApplicationConfigurationKey
import ai.tock.bot.admin.bot.BotConfiguration
import ai.tock.bot.admin.indicators.metric.Metric
import ai.tock.bot.admin.indicators.metric.MetricDAO
import ai.tock.bot.admin.story.StoryDefinitionConfiguration
import ai.tock.bot.admin.story.StoryDefinitionConfigurationDAO
import ai.tock.bot.connector.Connector
import ai.tock.bot.connector.ConnectorConfiguration
import ai.tock.bot.connector.ConnectorProvider
import ai.tock.bot.connector.ConnectorService
import ai.tock.bot.connector.ConnectorType
import ai.tock.bot.connector.NotifyBotStateModifier
import ai.tock.bot.definition.BotAnswerInterceptor
import ai.tock.bot.definition.BotDefinition
import ai.tock.bot.definition.BotProvider
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.engine.action.ActionNotificationType
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
import ai.tock.bot.engine.monitoring.RequestTimer
import ai.tock.bot.engine.nlp.BuiltInKeywordListener
import ai.tock.bot.engine.nlp.NlpController
import ai.tock.bot.engine.nlp.NlpListener
import ai.tock.bot.engine.user.PlayerId
import ai.tock.bot.engine.user.UserTimelineDAO
import ai.tock.nlp.api.client.NlpClient
import ai.tock.shared.Executor
import ai.tock.shared.defaultLocale
import ai.tock.shared.error
import ai.tock.shared.injector
import ai.tock.shared.jackson.addConstrainedTypes
import ai.tock.shared.listProperty
import ai.tock.shared.provide
import ai.tock.shared.vertx.vertx
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

/**
 * Advanced bot configuration.
 *
 * [ai.tock.bot.registerAndInstallBot] method is the preferred way to start a bot in most use cases.
 */
object BotRepository {

    private val logger = KotlinLogging.logger {}

    // load only specified configuration ids (dev mode)
    private val restrictedConfigurationIds: List = listProperty("tock_restricted_configuration_id", emptyList())
    private val statsMetricDAO: MetricDAO get() = injector.provide()
    private val botConfigurationDAO: BotApplicationConfigurationDAO get() = injector.provide()
    private val storyDefinitionConfigurationDAO: StoryDefinitionConfigurationDAO get() = injector.provide()
    internal val botProviders: MutableMap = ConcurrentHashMap()
    internal val storyHandlerListeners: MutableList = CopyOnWriteArrayList()
    private val nlpListeners: MutableList = CopyOnWriteArrayList(listOf(BuiltInKeywordListener))
    internal val nlpClient: NlpClient get() = injector.provide()
    private val nlpController: NlpController get() = injector.provide()
    private val executor: Executor get() = injector.provide()
    internal val botAnswerInterceptors: MutableList = CopyOnWriteArrayList()
    private val connectorServices: MutableSet =
        CopyOnWriteArraySet(ServiceLoader.load(ConnectorService::class.java).toList())

    internal val detailedHealthcheckTasks: MutableList Boolean>> =
        mutableListOf(Pair("nlp_client") { nlpClient.healthcheck() });

    internal val connectorProviders: MutableSet = CopyOnWriteArraySet(
        ServiceLoader.load(ConnectorProvider::class.java).map { it }.apply {
            forEach {
                logger.info { "Connector ${it.connectorType} loaded" }
                addConstrainedTypes(it.supportedResponseConnectorMessageTypes)
            }
        }
    )

    internal val connectorControllerMap: ConcurrentHashMap =
        ConcurrentHashMap()

    private val applicationIdBotApplicationConfigurationMap:
            ConcurrentHashMap = ConcurrentHashMap()

    @Volatile
    internal var botsInstalled: Boolean = false

    /**
     * Request timer for connectors.
     */
    @Volatile
    var requestTimer: RequestTimer = object : RequestTimer {}

    @Volatile
    private var realHealthCheckHandler: (RoutingContext) -> Unit = {
        if (!botsInstalled) {
            it.response().setStatusCode(500).end()
        } else {
            executor.executeBlocking {
                it.response().setStatusCode(if (nlpClient.healthcheck()) 200 else 500).end()
            }
        }
    }

    /**
     * healthcheck handler to answer to GET /healthcheck.
     */
    var healthcheckHandler: (RoutingContext) -> Unit
        get() = realHealthCheckHandler
        set(h) {
            realHealthCheckHandler = {
                if (!botsInstalled) {
                    it.response().setStatusCode(500).end()
                } else {
                    try {
                        h.invoke(it)
                    } catch (t: Throwable) {
                        logger.error(t)
                        try {
                            it.response().setStatusCode(500).end()
                        } catch (t: Throwable) {
                            logger.error(t)
                        }
                    }
                }
            }
        }

    private val verticle by lazy { BotVerticle() }

    /**
     * Calls the specified [action] for each registered [NlpListener].
     */
    fun forEachNlpListener(action: (NlpListener) -> Unit) {
        nlpListeners.forEach(action)
    }

    /**
     * Sends a notification to a connector.
     * A [Bus] is created and the corresponding story is called.
     *
     * @param applicationId the configuration connector id
     * @param recipientId the recipient identifier
     * @param intent the notification intent
     * @param step the optional step target
     * @param parameters the optional parameters
     * @param stateModifier allow the notification to bypass current user state
     * @param notificationType the notification type if any
     * @param errorListener called when a message has not been delivered
     */
    @Deprecated(
        "use ai.tock.bot.definition.notify",
        replaceWith = ReplaceWith("notify", "ai.tock.bot.definition.notify")
    )
    fun notify(
        applicationId: String,
        recipientId: PlayerId,
        intent: IntentAware,
        step: StoryStep? = null,
        parameters: Map = emptyMap(),
        stateModifier: NotifyBotStateModifier = NotifyBotStateModifier.KEEP_CURRENT_STATE,
        notificationType: ActionNotificationType? = null,
        namespace: String? = null,
        botId: String? = null,
        errorListener: (Throwable) -> Unit = {}
    ) {
        val key = if (namespace == null || botId == null) {
            logger.warn { "notify without specifying namespace or botId will be removed in next release" }
            applicationIdBotApplicationConfigurationMap.keys.firstOrNull { it.applicationId == applicationId }
        } else {
            BotApplicationConfigurationKey(applicationId = applicationId, namespace = namespace, botId = botId)
        }
        val conf = key?.let { getConfigurationByApplicationId(it) } ?: error("unknown application $applicationId")
        connectorControllerMap.getValue(conf)
            .notifyAndCheckState(recipientId, intent, step, parameters, stateModifier, notificationType, errorListener)
    }

    private fun ConnectorController.notifyAndCheckState(
        recipientId: PlayerId,
        intent: IntentAware,
        step: StoryStep?,
        parameters: Map,
        stateModifier: NotifyBotStateModifier,
        notificationType: ActionNotificationType?,
        errorListener: (Throwable) -> Unit = {}
    ) {
        val userTimelineDAO: UserTimelineDAO = injector.provide()
        val userTimeline = userTimelineDAO.loadWithoutDialogs(botDefinition.namespace, recipientId)
        val userState = userTimeline.userState
        val currentState = userState.botDisabled

        if (stateModifier == NotifyBotStateModifier.ACTIVATE_ONLY_FOR_THIS_NOTIFICATION ||
            stateModifier == NotifyBotStateModifier.REACTIVATE
        ) {
            userState.botDisabled = false
            userTimelineDAO.save(userTimeline, botDefinition)
        }

        notify(recipientId, intent, step, parameters, notificationType, errorListener)

        if (stateModifier == NotifyBotStateModifier.ACTIVATE_ONLY_FOR_THIS_NOTIFICATION) {
            val userTimelineAfterNotification = userTimelineDAO.loadWithoutDialogs(botDefinition.namespace, recipientId)
            userTimelineAfterNotification.userState.botDisabled = currentState
            userTimelineDAO.save(userTimeline, botDefinition)
        }
    }

    /**
     * Registers a new [ConnectorProvider].
     */
    internal fun registerConnectorProvider(connectorProvider: ConnectorProvider) {
        connectorProviders.add(connectorProvider)
    }

    /**
     * Registers a new [BotProvider].
     */
    fun registerBotProvider(bot: BotProvider) {
        botProviders[bot.botProviderId] = bot
    }

    /**
     * Register built-in story definitions.
     */
    fun registerBuiltInStoryDefinitions(botProvider: BotProvider) {
        val botDefinition = botProvider.botDefinition()
        checkBuiltInStoryCompliance(botDefinition)
        val configurationName = botProvider.botProviderId.configurationName
        executor.executeBlocking {
            storyDefinitionConfigurationDAO.createBuiltInStoriesIfNotExist(
                botDefinition.stories
                    .filter { it.mainIntent() != Intent.unknown }
                    .map { storyDefinition ->
                        StoryDefinitionConfiguration(botDefinition, storyDefinition, configurationName)
                    }
            )
        }
    }

    private fun checkBuiltInStoryCompliance(botDefinition: BotDefinition) {
        val starterIntentsMap: MutableMap> = mutableMapOf()
        botDefinition.stories.map { s ->
            s.starterIntents.forEach {
                val l = starterIntentsMap.getOrPut(it.name) { mutableListOf() }
                l.add(s)
            }
        }
        val duplicates = starterIntentsMap.mapValues { s -> s.value.distinctBy { it.id } }.filter { it.value.size > 1 }
        if (duplicates.isNotEmpty()) {
            error("duplicate starter intents: $duplicates")
        }
    }

    /**
     * Registers a new [StoryHandlerListener].
     */
    fun registerStoryHandlerListener(listener: StoryHandlerListener) {
        storyHandlerListeners.add(listener)
    }

    /**
     * Registers a new [BotAnswerInterceptor].
     */
    fun registerBotAnswerInterceptor(botAnswerInterceptor: BotAnswerInterceptor) {
        botAnswerInterceptors.add(botAnswerInterceptor)
    }

    /**
     * Registers a new [NlpListener].
     */
    fun registerNlpListener(listener: NlpListener) {
        nlpListeners.add(listener)
    }

    fun registerConnectorService(service: ConnectorService) {
        connectorServices.add(service)
    }

    /**
     * Register a new task to be check by the detailed healthcheck.
     * A task as a name and check action that return true if the service is good and false if it's KO.
     */
    fun registerDetailedHealtcheckTask(task: Pair Boolean>) {
        detailedHealthcheckTasks.add(task)
    }

    internal fun getConfigurationByApplicationId(key: BotApplicationConfigurationKey): BotApplicationConfiguration? =
        applicationIdBotApplicationConfigurationMap[key]

    /**
     * Returns the current [ConnectorController] for a given predicate.
     */
    fun getController(predicate: (BotApplicationConfiguration) -> Boolean): ConnectorController? =
        connectorControllerMap
            .keys
            .firstOrNull(predicate)
            ?.let {
                connectorControllerMap[it]
            }

    /**
     * Installs the bot(s).
     *
     * @param routerHandlers the additional router handlers
     * @param createApplicationIfNotExists create an nlp application if not exists
     * @param startupLock if not null, wait do listen until the lock is released
     */
    fun installBots(
        routerHandlers: List<(Router) -> Any?>,
        createApplicationIfNotExists: Boolean = true,
        startupLock: Lock? = null
    ) {
        val bots = botProviders.values.map { it.botDefinition() }

        // check that nlp applications exist
        if (createApplicationIfNotExists) {
            bots.distinctBy { it.namespace to it.nlpModelName }
                .forEach { botDefinition ->
                    try {
                        nlpClient.createApplication(
                            botDefinition.namespace,
                            botDefinition.nlpModelName,
                            defaultLocale
                        )?.apply {
                            logger.info { "nlp application initialized $namespace $name with locale $supportedLocales" }
                        }
                    } catch (e: Exception) {
                        logger.error(e)
                    }
                }
        }

        // persist builtin stories
        botProviders.values.forEach {
            registerBuiltInStoryDefinitions(it)
        }

        // load configurations
        try {
            checkBotConfigurations(startup = true)
        } catch (e: Exception) {
            logger.error(e)
        }

        // register services
        routerHandlers.forEachIndexed { index, handler ->
            verticle.registerServices("_handler_$index", handler)
        }

        // deploy verticle
        if (botsInstalled) {
            logger.warn { "bot already installed - try to configure new confs" }
            verticle.configure()
        } else {
            val lockFree = try {
                startupLock?.tryLock(5, MINUTES) ?: true
            } catch (e: InterruptedException) {
                logger.error(e)
                false
            }
            if (lockFree) {
                vertx.deployVerticle(verticle) {
                    if (it.succeeded()) {
                        logger.info { "Bots installed" }
                        botsInstalled = true
                        // listen future changes
                        botConfigurationDAO.listenChanges { checkAsyncBotConfigurations() }
                        botConfigurationDAO.listenBotChanges { checkAsyncBotConfigurations(true) }
                    } else {
                        logger.error("Bots installation failure", it.cause() ?: IllegalArgumentException())
                    }
                }
            } else {
                logger.error("Lock is not free")
            }
        }
    }

    /**
     * Returns the [ConnectorProvider] for the specified [ConnectorType].
     */
    fun findConnectorProvider(connectorType: ConnectorType): ConnectorProvider? {
        return connectorProviders.firstOrNull { it.connectorType == connectorType }
    }

    private fun checkAsyncBotConfigurations(botConfigurationChanged: Boolean = false) {
        executor.executeBlocking { checkBotConfigurations(botConfigurationChanged = botConfigurationChanged) }
    }

    /**
     * Checks that configurations are synchronized with the database.
     */
    @Synchronized
    fun checkBotConfigurations(startup: Boolean = false, botConfigurationChanged: Boolean = false) {
        logger.debug { "check configurations" }
        // the application definition cache
        val botConfigurationsCache = mutableSetOf()
        // the existing confs mapped by path
        val existingConfsByPath: Map = connectorControllerMap.keys
            .groupBy { it.path }.mapValues { it.value.first() }
        // the existing confs mapped by id
        val existingConfsById: Map, BotApplicationConfiguration> =
            connectorControllerMap.keys
                .groupBy { it._id }.mapValues { it.value.first() }
        // path -> botAppConf
        val confs: Map, BotApplicationConfiguration> =
            botConfigurationDAO
                .getConfigurations()
                .groupBy { it._id }
                .mapValues { it.value.first() }
                .filter { restrictedConfigurationIds.isEmpty() || restrictedConfigurationIds.contains(it.value.applicationId) }

        confs.values.forEach { c ->
            // gets the provider
            val provider = botProviders[BotProviderId(c.botId, c.namespace, c.name)]
                ?: botProviders[BotProviderId(c.botId, c.namespace)]

            // is there a configuration change ?
            if (provider != null &&
                (
                        provider.configurationUpdated ||
                                botConfigurationChanged ||
                                existingConfsByPath[c.path]?.takeIf { c.equalsWithoutId(it) } == null
                        )
            ) {
                val botDefinition = provider.botDefinition()
                if (botDefinition.namespace == c.namespace) {
                    logger.debug { "refresh configuration $c" }
                    val oldConfiguration = existingConfsById[c._id]
                    val oldConfigurationController = oldConfiguration?.let { connectorControllerMap[it] }
                    try {
                        val connector = findConnectorProvider(c.connectorType)?.connector(ConnectorConfiguration(c))
                        if (connector != null) {
                            // install new conf
                            createBot(botDefinition, connector, c, botConfigurationsCache)
                            if (oldConfigurationController != null) {
                                // remove old conf
                                removeBot(oldConfigurationController)
                                if (oldConfiguration != c) {
                                    connectorControllerMap.remove(oldConfiguration)
                                }
                            }
                        } else {
                            logger.warn { "unknown connector ${c.connectorType}" }
                        }
                    } catch (e: Throwable) {
                        logger.error(e)
                    }
                } else {
                    logger.trace { "not valid namespace for bot ${c.botId} - installation skipped" }
                }
            }
        }

        // remove deleted confs
        existingConfsById.values.forEach { conf ->
            if (!confs.containsKey(conf._id)) {
                removeBot(conf)
            }
        }

        // updates of all bot providers are now ok
        botProviders.values.forEach { it.configurationUpdated = false }

        if (!startup) {
            // register new confs
            verticle.configure()
        }
        logger.debug { "end check configurations" }
    }

    private fun createBot(
        botDefinition: BotDefinition,
        connector: Connector,
        conf: BotApplicationConfiguration,
        botConfigurationsCache: MutableSet
    ): BotApplicationConfiguration {

        val botConfiguration =
            botConfigurationsCache.find { it.botId == conf.botId && it.namespace == conf.namespace && it.name == conf.name }
                ?: botConfigurationDAO.getBotConfigurationsByNamespaceAndNameAndBotId(
                    conf.namespace,
                    conf.name,
                    conf.botId
                )
                ?: BotConfiguration(
                    name = conf.name,
                    botId = conf.botId,
                    namespace = conf.namespace,
                    nlpModel = conf.nlpModel
                )

        val supportedLocales = if (botConfiguration.supportedLocales.isEmpty()) {
            try {
                nlpController.waitAvailability()
                val app = nlpClient.getApplicationByNamespaceAndName(
                    botDefinition.namespace,
                    botDefinition.nlpModelName
                )

                val locales = app?.supportedLocales
                if (locales != null) {
                    val newBotConf = botConfiguration.copy(supportedLocales = locales)
                    botConfigurationDAO.save(newBotConf)
                    botConfigurationsCache.add(newBotConf)
                }
                locales ?: emptySet()
            } catch (e: Exception) {
                logger.error(e)
                emptySet()
            }
        } else {
            botConfigurationsCache.add(botConfiguration)
            botConfiguration.supportedLocales
        }

        if (supportedLocales.isEmpty()) {
            logger.warn { "no supported locales found for ${botDefinition.namespace}:${botDefinition.nlpModelName}" }
        } else {
            logger.debug { "locales for ${botDefinition.namespace}:${botDefinition.nlpModelName}: $supportedLocales" }
        }
        val bot = Bot(botDefinition, conf, supportedLocales)
        return botConfigurationDAO.save(conf)
            .apply {
                val controller = TockConnectorController.register(connector, bot, verticle, conf)
                // install connector services
                connectorServices.forEach { connectorService ->
                    connectorService.install(controller, conf)
                }
                // monitor bot
                StoryConfigurationMonitor.monitor(bot)
                BotRAGConfigurationMonitor.monitor(bot)
                BotObservabilityConfigurationMonitor.monitor(bot)
                BotVectorStoreConfigurationMonitor.monitor(bot)
                // register connector controller map
                connectorControllerMap[this] = controller
                applicationIdBotApplicationConfigurationMap[toKey()] = this
            }
    }

    private fun removeBot(conf: BotApplicationConfiguration) {
        logger.debug { "uninstall $conf" }
        val controller = connectorControllerMap.remove(conf)
        removeBot(controller)
    }

    private fun removeBot(controller: ConnectorController?) {
        if (controller != null) {
            logger.debug { "unregister $controller" }
            controller.unregisterServices()
            if (controller is TockConnectorController) {
                StoryConfigurationMonitor.unmonitor(controller.bot)
                BotRAGConfigurationMonitor.unmonitor(controller.bot)
                BotObservabilityConfigurationMonitor.unmonitor(controller.bot)
                BotVectorStoreConfigurationMonitor.unmonitor(controller.bot)
                TockConnectorController.unregister(controller)
            }
        }
    }

    /**
     * Delegation method to save one [Metric]
     * @param metric a [Metric] to save
     */
    fun saveMetric(metric: Metric) = statsMetricDAO.save(metric)

    /**
     * Delegation method to save many [Metric]
     * @param metrics a set of [Metric] to save
     */
    fun saveMetrics(metrics: List) = statsMetricDAO.saveAll(metrics)
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy