UNPKG

@litexa/core

Version:

Litexa, a programming language for writing Alexa skills

606 lines (481 loc) 21.4 kB
### # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ # Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. # SPDX-License-Identifier: Apache-2.0 # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ ### # causes every request and response object to be written to the logs loggingLevel = process?.env?.loggingLevel ? null # when enabled, logs out every state transition when it happens, useful for tracing what # order things happened in when something goes wrong logStateTraces = process?.env?.logStateTraces in [ 'true', true ] enableStateTracing = (process?.env?.enableStateTracing in [ 'true', true ]) or logStateTraces # hack for over aggressive Show caching shouldUniqueURLs = process?.env?.shouldUniqueURLs == 'true' # assets root location is determined by an external variable litexa.assetsRoot = process?.env?.assetsRoot ? litexa.assetsRoot exports.handlerSteps = handlerSteps = {} exports.handler = (event, lambdaContext, callback) -> handlerContext = originalEvent: event litexa: litexa # patch for testing support to be able to toggle this without # recreating the lambda if event.__logStateTraces? logStateTraces = event.__logStateTraces switch loggingLevel when 'verbose' # when verbose logging, dump the whole event to the console # this is pretty quick, but it makes for massive logs exports.Logging.log "VERBOSE REQUEST " + JSON.stringify(event, null, 2) when 'terse' exports.Logging.log "VERBOSE REQUEST " + JSON.stringify(event.request, null, 2) # patch when missing so downstream doesn't have to check unless event.session? event.session = {} unless event.session.attributes? event.session.attributes = {} handlerSteps.extractIdentity(event, handlerContext) .then -> handlerSteps.checkFastExit(event, handlerContext) .then (proceed) -> unless proceed return callback null, {} handlerSteps.runConcurrencyLoop(event, handlerContext) .then (response) -> # if we have post process extensions, then run each one in series promise = Promise.resolve() for extensionName, events of extensionEvents if events.beforeFinalResponse? try await events.beforeFinalResponse response catch err exports.Logging.error "Failed to execute the beforeFinalResponse event for extension #{extensionName}: #{err}" throw err return response .then (response) -> # if we're fully resolved here, we can return the final result if loggingLevel exports.Logging.log "VERBOSE RESPONSE " + JSON.stringify(response, null, 2) callback null, response .catch (err) -> # otherwise, we've failed, so return as an error, without data callback err, null handlerSteps.extractIdentity = (event, handlerContext) -> new Promise (resolve, reject) -> # extract the info we consider to be the user's identity. Note # different events may provide this information in different places handlerContext.identity = identity = {} if event.context?.System? identity.requestAppId = event.context.System.application?.applicationId identity.userId = event.context.System.user?.userId identity.deviceId = event.context.System.device?.deviceId else if event.session? identity.requestAppId = event.session.application?.applicationId identity.userId = event.session.user?.userId identity.deviceId = 'no-device' resolve() getLanguage = (event) -> # work out the language, from the locale, if it exists language = 'default' if event.request.locale? lang = event.request.locale langCode = lang[0...2] for __language of __languages if (lang.toLowerCase() is __language.toLowerCase()) or (langCode is __language) language = __language return language handlerSteps.checkFastExit = (event, handlerContext) -> # detect fast exit for valid events we don't route yet, or have no response to terminalEvent = false switch event.request.type when 'System.ExceptionEncountered' exports.Logging.error "ERROR System.ExceptionEncountered: #{JSON.stringify(event.request)}" terminalEvent = true when 'SessionEndedRequest' terminalEvent = true unless terminalEvent return true # this is an event that ends the session, but we may have code # that needs to cleanup on skill exist that result in a BD write new Promise (resolve, reject) -> originalSessionAttributes = JSON.parse JSON.stringify event.session.attributes tryToClose = -> dbKey = litexa.overridableFunctions.generateDBKey(handlerContext.identity) db.fetchDB { identity: handlerContext.identity, dbKey, sessionAttributes: originalSessionAttributes, fetchCallback: (err, dbObject) -> if err? return reject(err) language = getLanguage(event) if litexa.sessionTerminatingCallback? stateContext = now: (new Date(event.request?.timestamp)).getTime() requestId: event.request.requestId language: language event: event request: event?.request ? {} db: new DBTypeWrapper dbObject, language sessionAttributes: event?.session?.attributes litexa.sessionTerminatingCallback(stateContext) # all clear, we don't have anything active if loggingLevel exports.Logging.log "VERBOSE Terminating input handler early" # write back the object, to clear our memory dbObject.finalize (err) -> return reject(err) if err? if dbObject.repeatHandler tryToClose() else return resolve(false) } tryToClose() handlerSteps.runConcurrencyLoop = (event, handlerContext) -> # to solve for concurrency, we keep state in a database # and support retrying all the logic after this point # in the event that the database layer detects a collision return new Promise (resolve, reject) -> numberOfTries = 0 requestTimeStamp = (new Date(event.request?.timestamp)).getTime() language = getLanguage(event) litexa.language = language handlerContext.identity.litexaLanguage = language runHandler = -> numberOfTries += 1 if numberOfTries > 1 exports.Logging.log "CONCURRENCY LOOP iteration #{numberOfTries}, denied db write" dbKey = litexa.overridableFunctions.generateDBKey(handlerContext.identity) sessionAttributes = JSON.parse JSON.stringify event.session.attributes db.fetchDB { identity: handlerContext.identity, dbKey, sessionAttributes: sessionAttributes, fetchCallback: (err, dbObject) -> # build the context object for the state machine try stateContext = say: [] reprompt: [] directives: [] shouldEndSession: false now: requestTimeStamp settings: {} traceHistory: [] requestId: event.request.requestId language: language event: event request: event.request ? {} db: new DBTypeWrapper dbObject, language sessionAttributes: sessionAttributes stateContext.settings = stateContext.db.read("__settings") ? { resetOnLaunch: true } unless dbObject.isInitialized() dbObject.initialize() await __languages[stateContext.language].enterState.initialize?(stateContext) await handlerSteps.parseRequestData stateContext await handlerSteps.initializeMonetization stateContext, event # in the special case of us launching the skill from cold, we want to # warm up the landing state first, before delivering it the intent if !stateContext.currentState and stateContext.handoffState await handlerSteps.enterLaunchHandoffState stateContext await handlerSteps.routeIncomingIntent stateContext await handlerSteps.walkStates stateContext response = await handlerSteps.createFinalResult stateContext if event.__reportStateTrace response.__stateTrace = stateContext.traceHistory if dbObject.repeatHandler # the db failed to save, repeat the whole process await runHandler() else resolve response catch err reject err } # kick off the first one await runHandler() handlerSteps.parseRequestData = (stateContext) -> request = stateContext.request # this is litexa's dynamic request context, i.e. accesible from litexa as $something stateContext.slots = request: request stateContext.oldInSkillProducts = stateContext.inSkillProducts = stateContext.db.read("__inSkillProducts") ? { inSkillProducts: [] } # note: # stateContext.handoffState : who will handle the next intent # stateContext.handoffIntent : which intent will be delivered next # stateContext.currentState : which state are we ALREADY in # stateContext.nextState : which state is queued up to be transitioned into next stateContext.handoffState = null stateContext.handoffIntent = false stateContext.currentState = stateContext.db.read "__currentState" stateContext.nextState = null if request.type == 'LaunchRequest' reportValueMetric 'Launches' initializeExtensionObjects stateContext switch request.type when 'IntentRequest', 'LaunchRequest' incomingState = stateContext.currentState # don't have a current state? Then we're going to launch unless incomingState incomingState = 'launch' stateContext.currentState = null # honor resetOnLaunch isColdLaunch = request.type == 'LaunchRequest' or stateContext.event.session?.new if stateContext.settings.resetOnLaunch and isColdLaunch incomingState = 'launch' stateContext.currentState = null if request?.intent intent = request.intent stateContext.intent = intent.name if intent.slots? for name, obj of intent.slots stateContext.slots[name] = obj.value authorities = obj.resolutions?.resolutionsPerAuthority ? [] for auth in authorities if auth? and auth.status?.code == 'ER_SUCCESS_MATCH' value = auth.values?[0]?.value?.name if value? stateContext.slots[name] = value stateContext.handoffIntent = true stateContext.handoffState = incomingState stateContext.nextState = null else stateContext.intent = null stateContext.handoffIntent = false stateContext.handoffState = null stateContext.nextState = incomingState when 'Connections.Response' stateContext.intent = 'Connections.Response' stateContext.handoffIntent = true # if we get this and we're not in progress, # then reroute to the launch state if stateContext.currentState? stateContext.handoffState = stateContext.currentState else stateContext.nextState = 'launch' stateContext.handoffState = 'launch' else stateContext.intent = request.type stateContext.handoffIntent = true stateContext.handoffState = stateContext.currentState stateContext.nextState = null handled = false for extensionName, requests of extensionRequests if request.type of requests handled = true func = requests[request.type] if typeof(func) == 'function' func(request) if request.type in litexa.extendedEventNames handled = true unless handled throw new Error "unrecognized event type: #{request.type}" handlerSteps.initializeMonetization = (stateContext, event) -> stateContext.monetization = stateContext.db.read("__monetization") unless stateContext.monetization? stateContext.monetization = { fetchEntitlements: false inSkillProducts: [] } stateContext.db.write "__monetization", stateContext.monetization if event.request?.type in [ 'Connections.Response', 'LaunchRequest' ] attributes = event.session.attributes # invalidate monetization cache stateContext.monetization.fetchEntitlements = true stateContext.db.write "__monetization", stateContext.monetization return Promise.resolve() handlerSteps.enterLaunchHandoffState = (stateContext) -> state = stateContext.handoffState unless state of __languages[stateContext.language].enterState throw new Error "Entering an unknown state `#{state}`" await __languages[stateContext.language].enterState[state](stateContext) stateContext.currentState = stateContext.handoffState if enableStateTracing stateContext.traceHistory.push stateContext.handoffState if logStateTraces item = "enter (at launch) #{stateContext.handoffState}" exports.Logging.log "STATETRACE " + item handlerSteps.routeIncomingIntent = (stateContext) -> if stateContext.nextState unless stateContext.nextState of __languages[stateContext.language].enterState # we've been asked to execute a non existant state! # in order to have a chance at recovering, we have to drop state # which means when next we launch we'll start over # todo: reroute to launch anyway? await new Promise (resolve, reject) -> stateContext.db.write "__currentState", null stateContext.db.finalize (err) -> reject new Error "Invalid state name `#{stateContext.nextState}`" # if we have an intent, handle it with the current state # but if that handler sets a handoff, then following that # and keep following them until we've actually handled it for i in [0...10] return unless stateContext.handoffIntent stateContext.handoffIntent = false if enableStateTracing item = "#{stateContext.handoffState}:#{stateContext.intent}" stateContext.traceHistory.push item if logStateTraces item = "drain intent #{stateContext.intent} in #{stateContext.handoffState}" exports.Logging.log "STATETRACE " + item await __languages[stateContext.language].processIntents[stateContext.handoffState]?(stateContext) throw new Error "Intent handler recursion error, exceeded 10 steps" handlerSteps.walkStates = (stateContext) -> # keep processing state transitions until we're done MaximumTransitionCount = 500 for i in [0...MaximumTransitionCount] # prime the next transition nextState = stateContext.nextState # stop if there isn't one unless nextState return # run the exit handler if there is one lastState = stateContext.currentState if lastState? await __languages[stateContext.language].exitState[lastState](stateContext) # check in case the exit handler caused a redirection nextState = stateContext.nextState unless nextState return # the state transition resets the next transition state # and implies that we'll go back to opening the mic stateContext.nextState = null stateContext.shouldEndSession = false delete stateContext.shouldDropSession stateContext.currentState = nextState if enableStateTracing stateContext.traceHistory.push nextState if logStateTraces item = "enter #{nextState}" exports.Logging.log "STATETRACE " + item unless nextState of __languages[stateContext.language].enterState throw new Error "Transitioning to an unknown state `#{nextState}`" await __languages[stateContext.language].enterState[nextState](stateContext) if stateContext.handoffIntent stateContext.handoffIntent = false if enableStateTracing stateContext.traceHistory.push stateContext.handoffState if logStateTraces item = "drain intent #{stateContext.intent} in #{stateContext.handoffState}" exports.Logging.log "STATETRACE " + item await __languages[stateContext.language].processIntents[stateContext.handoffState]?(stateContext) exports.Logging.error "States error: exceeded #{MaximumTransitionCount} transitions." if enableStateTracing exports.Logging.error "States visited: [#{stateContext.traceHistory.join(' -> ')}]" else exports.Logging.error "Set 'enableStateTracing' to get a history of which states were visited." throw new Error "States error: exceeded #{MaximumTransitionCount} transitions. Check your logic for non-terminating loops." handlerSteps.createFinalResult = (stateContext) -> stripSSML = (line) -> return undefined unless line? line = line.replace /<[^>]+>/g, '' line.replace /[ ]+/g, ' ' # invoke any 'afterStateMachine' extension events for extensionName, events of extensionEvents try await events.afterStateMachine?() catch err exports.Logging.error "Failed to execute afterStateMachine for extension #{extensionName}: #{err}" throw err hasDisplay = stateContext.event.context?.System?.device?.supportedInterfaces?.Display? # start building the final response json object wrapper = version: "1.0" sessionAttributes: stateContext.sessionAttributes userAgent: userAgent # this userAgent value is generated in project-info.coffee and injected in skill.coffee response: shouldEndSession: stateContext.shouldEndSession response = wrapper.response if stateContext.shouldDropSession delete response.shouldEndSession # build outputSpeech and reprompt from the accumulators joinSpeech = (arr, language = 'default') -> return '' unless arr result = arr[0] for line in arr[1..] # If the line starts with punctuation, don't add a space before. if line.match /^[?!:;,.]/ result += line else result += " #{line}" result = result.replace /( )/g, ' ' if litexa.sayMapping[language] for mapping in litexa.sayMapping[language] result = result.replace mapping.from, mapping.to return result if stateContext.say? and stateContext.say.length > 0 response.outputSpeech = type: "SSML" ssml: "<speak>#{joinSpeech(stateContext.say, stateContext.language)}</speak>" playBehavior: "REPLACE_ALL" if stateContext.reprompt? and stateContext.reprompt.length > 0 response.reprompt = outputSpeech: type: "SSML", ssml: "<speak>#{joinSpeech(stateContext.reprompt, stateContext.language)}</speak>" if stateContext.card? card = stateContext.card title = card.title ? "" content = card.content ? "" if card.repeatSpeech and stateContext.say? parts = for s in stateContext.say stripSSML(s) content += parts.join('\n') content = content ? "" response.card = type: "Simple" title: title ? "" response.card.title = response.card.title.trim() if card.imageURLs? response.card.type = "Standard" response.card.text = content ? "" response.card.image = smallImageUrl: card.imageURLs.cardSmall largeImageUrl: card.imageURLs.cardLarge response.card.text = response.card.text.trim() else response.card.type = "Simple" response.card.content = content response.card.content = response.card.content.trim() keep = false keep = true if response.card.title.length > 0 keep = true if response.card.text?.length > 0 keep = true if response.card.content?.length > 0 keep = true if response.card.image?.smallImageUrl? keep = true if response.card.image?.largeImageUrl? unless keep delete response.card if stateContext.musicCommand? stateContext.directives = stateContext.directives ? [] switch stateContext.musicCommand.action when 'play' stateContext.directives.push type: "AudioPlayer.Play" playBehavior: "REPLACE_ALL" audioItem: stream: url: stateContext.musicCommand.url token: "no token" offsetInMilliseconds: 0 when 'stop' stateContext.directives.push type: "AudioPlayer.Stop" # store current state for next time, unless we're intentionally ending if stateContext.shouldEndSession stateContext.currentState = null if stateContext.currentState == null response.shouldEndSession = true stateContext.db.write "__currentState", stateContext.currentState stateContext.db.write "__settings", stateContext.settings # filter out any directives that were marked for removal stateContext.directives = ( d for d in stateContext.directives when not d?.DELETEME ) if stateContext.directives? and stateContext.directives.length > 0 response.directives = stateContext.directives # last chance, see if the developer left a postprocessor to run here if litexa.responsePostProcessor? litexa.responsePostProcessor wrapper, stateContext if stateContext.shouldEndSession && litexa.sessionTerminatingCallback? # we're about to quit, won't get session ended, # so this counts as the very last moment in this session litexa.sessionTerminatingCallback(stateContext) return await new Promise (resolve, reject) -> stateContext.db.finalize (err, info) -> if err? unless db.repeatHandler reject err resolve wrapper