Skip to content
← 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 · TypeScript
Raw
import { 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,
	)
}