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 } type AlexaBody = { version?: string session?: { application?: { applicationId?: string } } context?: { System?: { application?: { applicationId?: string } } } request?: { type?: string intent?: AlexaIntent } } type SessionAttributes = Record 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 { 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 | 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 | 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 { const record = isRecord(raw) ? raw : {} const webhookMode = 'request' in record || 'webhook' in record let body: AlexaBody | null = null let rawBody = '' let headers: Record | 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) : 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, ) }