/** * Alexa channel provider. * * Unlike WhatsApp, this channel has no socket, no QR, no auth state of its own. * It's a degenerate provider whose `sendMessage` resolves a promise held by an * inbound HTTP handler. The relay (api.bloby.bot) is the public entry point that * verifies Amazon's signature, then forwards the parsed utterance to this Pi * via POST /api/channels/alexa/handle. That route waits for the agent's reply * and returns it as the HTTP response, which the relay then formats as the * Alexa-flavored JSON envelope. * * The promise-resolution flow is per-conversation FIFO, mirroring the same * anti-bleed contract WhatsApp uses: each pushed utterance reserves a resolver * slot, each bot:response consumes the head slot. */ import { loadConfig } from '../../shared/config.js'; import { log } from '../../shared/logger.js'; import type { ChannelProvider, ChannelStatus, ChannelType } from './types.js'; /** Credentials + state needed to fire Progressive Response for a single Alexa turn. */ export interface AlexaTurnState { apiEndpoint: string; apiAccessToken: string; requestId: string; /** Wall-clock start of the turn — used for log timing. */ startedAt: number; /** Static-fallback timer that fires "Working on it" if no preamble text arrives in time. */ fallbackTimer: ReturnType | null; /** True once we've sent at least one Progressive Response for this turn. */ sentAny: boolean; } interface PendingSlot { resolve: (text: string) => void; reject: (err: Error) => void; createdAt: number; turn: AlexaTurnState | null; } const STATIC_FALLBACK_MS = 1_500; // Fire early enough to extend Alexa's budget on cold start const MAX_PROGRESSIVE_SPEECH = 600; export class AlexaChannel implements ChannelProvider { readonly type: ChannelType = 'alexa'; /** Per-conversation FIFO of pending HTTP-response resolvers + turn state. * Each inbound Alexa utterance enqueues one slot; each bot:response (or * turn-complete safety net) dequeues one. */ private pending = new Map(); /** Reserve a resolver slot. The caller pushes the user utterance into the * live conversation IMMEDIATELY after this returns, so the FIFO order on * this map matches the FIFO order the routing queue uses. * * If `creds` are provided (apiEndpoint + apiAccessToken + requestId), this * also schedules a static "Working on it" Progressive Response to fire if * the agent emits no preamble text within STATIC_FALLBACK_MS. The fallback * is cancelled the moment any Progressive Response is sent for this turn. */ reservePending( convId: string, creds: { apiEndpoint: string; apiAccessToken: string; requestId: string } | null, timeoutMs = 25_000, ): Promise { return new Promise((resolve, reject) => { const turn: AlexaTurnState | null = creds ? { apiEndpoint: creds.apiEndpoint, apiAccessToken: creds.apiAccessToken, requestId: creds.requestId, startedAt: Date.now(), fallbackTimer: null, sentAny: false, } : null; const slot: PendingSlot = { resolve, reject, createdAt: Date.now(), turn }; let q = this.pending.get(convId); if (!q) { q = []; this.pending.set(convId, q); } q.push(slot); // NOTE: the relay fires an immediate "On it." Progressive Response on // every AgentIntent turn (see relay's sendImmediateProgressive), so the // budget is already extended by the time we get here. We DON'T schedule // a static fallback on the Pi side — it would double up and the user // would hear "On it. Working on it." If the agent emits a preamble, we // still flush it as a Progressive in `tryFlushProgressive`. // Hard safety timeout — if the agent never responds at all, the HTTP // handler unblocks so the relay can return a friendly fallback. The // slot is removed so a late bot:response doesn't resolve a dead promise. setTimeout(() => { const list = this.pending.get(convId); if (!list) return; const idx = list.indexOf(slot); if (idx >= 0) { if (slot.turn?.fallbackTimer) clearTimeout(slot.turn.fallbackTimer); list.splice(idx, 1); if (list.length === 0) this.pending.delete(convId); reject(new Error('alexa-timeout')); } }, timeoutMs); }); } /** Resolve the head resolver for a conversation. Called by the channel * manager when bot:response (or turn-complete with no response) fires for * an alexa-surface turn. */ resolveHead(convId: string, text: string): boolean { const q = this.pending.get(convId); if (!q || q.length === 0) return false; const slot = q.shift()!; if (q.length === 0) this.pending.delete(convId); if (slot.turn?.fallbackTimer) clearTimeout(slot.turn.fallbackTimer); slot.resolve(text); return true; } /** Drop the head resolver (used when a turn ends without a bot:response). */ rejectHead(convId: string, reason: string): boolean { const q = this.pending.get(convId); if (!q || q.length === 0) return false; const slot = q.shift()!; if (q.length === 0) this.pending.delete(convId); if (slot.turn?.fallbackTimer) clearTimeout(slot.turn.fallbackTimer); slot.reject(new Error(reason)); return true; } /** Try to flush a buffered preamble chunk as Progressive Response on the * head turn. Called by the channel manager on bot:tool events when the * routing target's surface is 'alexa' and there's buffered text. */ tryFlushProgressive(convId: string, text: string): boolean { const q = this.pending.get(convId); if (!q || q.length === 0) return false; const turn = q[0].turn; if (!turn) return false; this.sendProgressive(turn, text).catch(() => {}); return true; } /** Fire a single Progressive Response directive to Amazon's Directive Service. * Best-effort: failures are logged but don't break the agent's stream. */ private async sendProgressive(turn: AlexaTurnState, speech: string): Promise { const trimmed = String(speech || '').trim(); if (!trimmed) return; const fireOffset = Date.now() - turn.startedAt; try { const r = await fetch(`${turn.apiEndpoint}/v1/directives`, { method: 'POST', headers: { Authorization: `Bearer ${turn.apiAccessToken}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ header: { requestId: turn.requestId }, directive: { type: 'VoicePlayer.Speak', speech: trimmed.slice(0, MAX_PROGRESSIVE_SPEECH), }, }), }); if (r.ok) { turn.sentAny = true; if (turn.fallbackTimer) { clearTimeout(turn.fallbackTimer); turn.fallbackTimer = null; } log.info(`[alexa/progressive] sent at +${fireOffset}ms (status ${r.status}) — "${trimmed.slice(0, 60)}"`); } else { const body = await r.text().catch(() => ''); log.warn(`[alexa/progressive] REJECTED at +${fireOffset}ms — status ${r.status} body=${body.slice(0, 200)}`); } } catch (err: any) { log.warn(`[alexa/progressive] FAILED at +${fireOffset}ms — ${err.message}`); } } // ── ChannelProvider implementation ── async connect(): Promise { // No-op. Alexa is a passive HTTP receiver. log.info('[alexa] Channel ready (passive HTTP receiver)'); } async disconnect(): Promise { // Reject any in-flight resolvers so HTTP handlers don't hang forever, // and cancel any pending fallback timers to avoid late progressive calls. for (const [, q] of this.pending) { for (const slot of q) { if (slot.turn?.fallbackTimer) clearTimeout(slot.turn.fallbackTimer); slot.reject(new Error('alexa-disconnected')); } } this.pending.clear(); } async sendMessage(_to: string, _text: string): Promise { // Alexa is request/response. Replies are delivered by resolving the // pending HTTP response, not by calling this method. ChannelManager // routes alexa-surface turns through `resolveHead`, not `sendMessage`. log.warn('[alexa] sendMessage called on Alexa channel — ignored (use resolveHead)'); } getStatus(): ChannelStatus { const cfg = loadConfig().channels?.alexa; return { channel: 'alexa', connected: !!cfg?.enabled, info: { linked: !!cfg?.sharedSecret, }, }; } getQrCode(): string | null { return null; } hasCredentials(): boolean { return !!loadConfig().channels?.alexa?.sharedSecret; } }