@litexa/core
Version:
Litexa, a programming language for writing Alexa skills
606 lines (481 loc) • 21.4 kB
text/coffeescript
###
# ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
# 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