/** * Voice-note reply ladder — reserve → download → transcribe → answer → * synthesize → voice-or-text. * * Channel-agnostic and framework-free like the rest of `src/voice/`: every * step is injected by the host, so this module carries no Twilio/webhook/chat * types and never reads env. Persona is entirely the host's concern (folded * into `answer` and `synthesize`) — this module knows nothing about it. * * THE INVARIANT: every branch attempts a delivered message. Voice is * attempted, text is guaranteed. A gate (is this sender eligible, is this * really a voice note) is the caller's job — the ladder starts after the * decision to reply. * * `sendText` is deliberately never wrapped in try/catch here: a failure to * deliver even the guaranteed fallback is not something this module can * paper over, so it propagates to the caller rather than resolving to a * silent "undeliverable" outcome. */ import type { VoiceLimitCheck } from "./limits"; import type { VoiceNote } from "./synthesize"; /** What actually happened, for logging and for tests to assert the ladder. */ export type VoiceReplyOutcome = "over-cap" | "limit-error" | "no-audio" | "transcribe-failed" | "answer-failed" | "voice" | "text"; /** * Pre-resolved fallback copy for one call. Resolve these fresh per invocation * (e.g. via `getVoicePhrase`) rather than caching the object: any array * variant in the phrase file rotates at resolution time, so reusing a built * object across calls silently freezes it on one variant. */ export interface VoiceReplyPhrases { overCapPerNumber: string; overCapGlobal: string; limitUnavailable: string; unintelligible: string; answerFailed: string; } export interface VoiceReplyDeps { /** Reserve one round-trip BEFORE any paid work. A rejection fails closed to text. */ reserve(): Promise; /** Pull the raw audio bytes for the inbound voice note. */ download(): Promise; /** Bytes → transcript, or null on any failure. */ transcribe(bytes: Uint8Array): Promise; /** The existing answer path — persona, RAG, everything applies to spoken questions too. */ answer(transcript: string): Promise; /** Text → voice note, or null when it can't be produced. */ synthesize(text: string): Promise; /** Speak the reply; false means "couldn't", and the caller falls back to text. */ sendVoice(note: VoiceNote): Promise; /** The guaranteed path. Failures here propagate — see module doc. */ sendText(text: string): Promise; phrases: VoiceReplyPhrases; } /** * Run the ladder for one eligible voice note. Eligibility (flag, trigger * rules, per-sender/group gates) is the caller's job — by the time this runs, * the decision to reply has been made and the only question is how. */ export declare function runVoiceReply(deps: VoiceReplyDeps): Promise;