← Public packages
@kody/alexa
Alexa custom skill webhook into Kody. Checks the skill id, maps common requests, and leaves utterance forwarding unwired.
src/skill-request.ts
314 lines · 9.1 KB · TypeScriptimport { any, boolean, object, optional, parseSafe } from 'remix/data-schema'
import verifyAlexaRequest from './verify.ts'
/**
* Skill id from the Alexa developer console (amzn1.ask.skill...).
* Create this secret in the Kody secret store. Do not commit a value.
*/
const alexaSkillApplicationId = '{{secret:alexaSkillApplicationId|scope=user}}'
/**
* Reserved for a later hop that POSTs the utterance onward from Kody.
* This stub never requests the URL, even when the secret is set.
*/
const alexaForwardUrl = '{{secret:alexaForwardUrl|scope=user}}'
const directInput = object(
{
body: any(),
dryRun: optional(boolean()),
allowUnsigned: optional(boolean()),
},
{ unknownKeys: 'error' },
)
type AlexaIntent = {
name?: string
slots?: Record<string, { name?: string; value?: string } | null | undefined>
}
type AlexaBody = {
version?: string
session?: {
application?: { applicationId?: string }
}
context?: {
System?: {
application?: { applicationId?: string }
}
}
request?: {
type?: string
intent?: AlexaIntent
}
}
type SessionAttributes = Record<string, string>
type AlexaResponse = {
version: '1.0'
sessionAttributes: SessionAttributes
response: {
outputSpeech?: { type: 'PlainText'; text: string }
reprompt?: { outputSpeech: { type: 'PlainText'; text: string } }
shouldEndSession: boolean
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
}
function secretIsConfigured(secret: string) {
const value = secret.trim()
if (!value) return false
if (value.includes('{{secret:')) return false
return true
}
function headerValue(
headers: Record<string, unknown> | undefined,
name: string,
) {
if (!headers) return ''
const wanted = name.toLowerCase()
for (const [key, value] of Object.entries(headers)) {
if (key.toLowerCase() === wanted && typeof value === 'string') {
return value.trim()
}
}
return ''
}
function asAlexaBody(value: unknown): AlexaBody | null {
if (!isRecord(value)) return null
return value as AlexaBody
}
function readApplicationId(body: AlexaBody) {
const fromSession = body.session?.application?.applicationId
if (typeof fromSession === 'string' && fromSession.trim()) return fromSession.trim()
const fromContext = body.context?.System?.application?.applicationId
if (typeof fromContext === 'string' && fromContext.trim()) return fromContext.trim()
return ''
}
function readUtterance(intent: AlexaIntent | undefined) {
const slots = intent?.slots
if (!slots || typeof slots !== 'object') return ''
const parts: string[] = []
for (const slot of Object.values(slots)) {
if (!slot || typeof slot.value !== 'string') continue
const value = slot.value.trim()
if (value) parts.push(value)
}
return parts.join(' ')
}
function speak(
text: string,
shouldEndSession: boolean,
sessionAttributes: SessionAttributes,
): AlexaResponse {
const response: AlexaResponse['response'] = {
outputSpeech: { type: 'PlainText', text },
shouldEndSession,
}
if (!shouldEndSession) {
response.reprompt = {
outputSpeech: {
type: 'PlainText',
text: 'Tell me what to send to Kody. Forwarding is not wired yet.',
},
}
}
return { version: '1.0', sessionAttributes, response }
}
function sessionEnded(sessionAttributes: SessionAttributes): AlexaResponse {
return {
version: '1.0',
sessionAttributes,
response: { shouldEndSession: true },
}
}
function resolveRawBody(request: Record<string, unknown> | undefined) {
if (!request) return ''
if (typeof request.body === 'string') return request.body
if (typeof request.text === 'string') return request.text
if (request.json !== undefined) return JSON.stringify(request.json)
return ''
}
/**
* Turn an Alexa custom skill request into Alexa response JSON.
* Use as the minted `skill` webhook (sync), or call with `{ body, dryRun }`
* to preview speech. Utterance forwarding from Kody is not wired.
*
* @param input - Kody webhook envelope `{ webhook, request }`, or `{ body, dryRun, allowUnsigned }` (unknown keys rejected on that direct shape)
* @returns Alexa response `{ version, sessionAttributes, response }`
*
* @example
* import skillRequest from 'kody:@kody/alexa/skill-request'
*
* const result = await skillRequest({
* body: {
* version: '1.0',
* request: { type: 'LaunchRequest' },
* },
* dryRun: true,
* })
*/
export default async function skillRequest(raw: unknown): Promise<AlexaResponse> {
const record = isRecord(raw) ? raw : {}
const webhookMode = 'request' in record || 'webhook' in record
let body: AlexaBody | null = null
let rawBody = ''
let headers: Record<string, unknown> | undefined
let dryRun = false
let allowUnsigned = false
let parseError = ''
if (webhookMode) {
const request = isRecord(record.request) ? record.request : undefined
headers = isRecord(request?.headers)
? (request.headers as Record<string, unknown>)
: undefined
rawBody = resolveRawBody(request)
if (request && request.json !== undefined && request.json !== null) {
body = asAlexaBody(request.json)
if (!body) parseError = 'Alexa request JSON must be an object.'
} else if (rawBody) {
try {
body = asAlexaBody(JSON.parse(rawBody) as unknown)
if (!body) parseError = 'Alexa request JSON must be an object.'
} catch {
parseError = 'Alexa request body was not valid JSON.'
}
} else {
parseError = 'Alexa request body was empty.'
}
dryRun = record.dryRun === true
allowUnsigned = record.allowUnsigned === true
} else {
const parsed = parseSafe(directInput, raw)
if (!parsed.success) {
const detail = parsed.issues
.map((issue) => issue.message)
.filter(Boolean)
.join('; ')
throw new Error(
detail
? `skill-request input rejected: ${detail}. Accepted direct keys: body, dryRun, allowUnsigned.`
: 'skill-request input rejected. Accepted direct keys: body, dryRun, allowUnsigned.',
)
}
dryRun = parsed.value.dryRun === true
allowUnsigned = parsed.value.allowUnsigned === true
const directBody = parsed.value.body
if (typeof directBody === 'string') {
rawBody = directBody
try {
body = asAlexaBody(JSON.parse(directBody) as unknown)
if (!body) parseError = 'Alexa request JSON must be an object.'
} catch {
parseError = 'Alexa request body was not valid JSON.'
}
} else {
rawBody = JSON.stringify(directBody ?? null)
body = asAlexaBody(directBody)
if (!body) parseError = 'Alexa request body must be an object or a JSON string.'
}
}
const signature = await verifyAlexaRequest({
signatureCertChainUrl: headerValue(headers, 'signaturecertchainurl') || undefined,
signature: headerValue(headers, 'signature') || undefined,
body: rawBody || undefined,
dryRun,
allowUnsigned,
})
const applicationConfigured = secretIsConfigured(alexaSkillApplicationId)
const forwardConfigured = secretIsConfigured(alexaForwardUrl)
const presentedId = body ? readApplicationId(body) : ''
let applicationIdCheck = 'skipped-no-secret'
if (dryRun) applicationIdCheck = 'skipped-dry-run'
else if (allowUnsigned) applicationIdCheck = 'skipped-allow-unsigned'
else if (applicationConfigured) {
applicationIdCheck =
presentedId && presentedId === alexaSkillApplicationId.trim()
? 'matched'
: 'mismatch'
}
const sessionAttributes: SessionAttributes = {
kodyAlexaStub: 'head-start',
signatureVerification: 'skipped-not-implemented-unsafe-for-production',
forward: forwardConfigured ? 'secret-set-not-wired' : 'not-wired',
applicationIdCheck,
dryRun: dryRun ? 'true' : 'false',
}
if (parseError || !body) {
return speak(
`I could not read that Alexa request. ${parseError || 'Missing body.'} Forwarding from Kody is not wired yet.`,
true,
sessionAttributes,
)
}
if (applicationIdCheck === 'mismatch') {
return speak(
'This skill request does not match the configured application id.',
true,
sessionAttributes,
)
}
const requestType = body.request?.type ?? ''
const unsafeNote = signature.devBypass
? ''
: ' Signature verification is not implemented, so this is not safe for production.'
const secretNote = applicationConfigured
? ''
: ' The alexaSkillApplicationId secret is not set.'
const forwardNote = forwardConfigured
? ' A forward URL secret is set, but this stub does not call it.'
: ' Forwarding from Kody is not wired yet.'
const prefix = dryRun ? 'Dry run. ' : ''
if (requestType === 'SessionEndedRequest') {
return sessionEnded(sessionAttributes)
}
if (requestType === 'LaunchRequest') {
return speak(
`${prefix}Alexa to Kody stub is ready.${forwardNote}${secretNote}${unsafeNote}`,
false,
sessionAttributes,
)
}
if (requestType === 'IntentRequest') {
const intentName = body.request?.intent?.name?.trim() || 'an unnamed intent'
const utterance = readUtterance(body.request?.intent)
const heard = utterance
? `I heard: ${utterance}.`
: `I heard the ${intentName} intent.`
return speak(
`${prefix}${heard}${forwardNote}${secretNote}${unsafeNote}`,
false,
sessionAttributes,
)
}
return speak(
`${prefix}This stub only maps LaunchRequest, IntentRequest, and SessionEndedRequest.${forwardNote}`,
true,
sessionAttributes,
)
}