|
| 1 | +// lib/services/whatsapp-voice.service.ts |
| 2 | +// Server-side service: synthesises a WhatsApp message body as an ElevenLabs |
| 3 | +// voice note, uploads the audio to the WhatsApp Media API, then sends it as |
| 4 | +// an audio message. Falls back silently when ElevenLabs is not configured. |
| 5 | + |
| 6 | +import { elevenLabsEnabled, synthesise } from '@/lib/elevenlabs'; |
| 7 | +import { isDemoMode, demoLog } from '@/lib/demo/demo-mode'; |
| 8 | + |
| 9 | +const GRAPH_API_VERSION = 'v21.0'; |
| 10 | + |
| 11 | +function whatsappEnabled(): boolean { |
| 12 | + return !!( |
| 13 | + process.env.WHATSAPP_PHONE_NUMBER_ID && |
| 14 | + (process.env.WHATSAPP_ACCESS_TOKEN || process.env.WHATSAPP_API_TOKEN) |
| 15 | + ); |
| 16 | +} |
| 17 | + |
| 18 | +/** |
| 19 | + * Upload raw audio bytes to the WhatsApp Media API. |
| 20 | + * Returns the media_id needed for the audio message payload. |
| 21 | + */ |
| 22 | +async function uploadAudio( |
| 23 | + audioBuffer: ArrayBuffer, |
| 24 | + phoneNumberId: string, |
| 25 | + accessToken: string, |
| 26 | +): Promise<string> { |
| 27 | + const blob = new Blob([audioBuffer], { type: 'audio/mpeg' }); |
| 28 | + const form = new FormData(); |
| 29 | + form.append('file', blob, 'voice-note.mp3'); |
| 30 | + form.append('type', 'audio/mpeg'); |
| 31 | + form.append('messaging_product', 'whatsapp'); |
| 32 | + |
| 33 | + const url = `https://graph.facebook.com/${GRAPH_API_VERSION}/${phoneNumberId}/media`; |
| 34 | + const res = await fetch(url, { |
| 35 | + method: 'POST', |
| 36 | + headers: { Authorization: `Bearer ${accessToken}` }, |
| 37 | + body: form, |
| 38 | + }); |
| 39 | + |
| 40 | + if (!res.ok) { |
| 41 | + const err = await res.text(); |
| 42 | + throw new Error(`[whatsapp-voice] media upload failed ${res.status}: ${err}`); |
| 43 | + } |
| 44 | + |
| 45 | + const data = await res.json(); |
| 46 | + return data.id as string; |
| 47 | +} |
| 48 | + |
| 49 | +/** |
| 50 | + * Send a pre-uploaded media file as a WhatsApp audio message. |
| 51 | + */ |
| 52 | +async function sendAudioMessage( |
| 53 | + to: string, |
| 54 | + mediaId: string, |
| 55 | + phoneNumberId: string, |
| 56 | + accessToken: string, |
| 57 | +): Promise<string> { |
| 58 | + const url = `https://graph.facebook.com/${GRAPH_API_VERSION}/${phoneNumberId}/messages`; |
| 59 | + const res = await fetch(url, { |
| 60 | + method: 'POST', |
| 61 | + headers: { |
| 62 | + Authorization: `Bearer ${accessToken}`, |
| 63 | + 'Content-Type': 'application/json', |
| 64 | + }, |
| 65 | + body: JSON.stringify({ |
| 66 | + messaging_product: 'whatsapp', |
| 67 | + recipient_type: 'individual', |
| 68 | + to, |
| 69 | + type: 'audio', |
| 70 | + audio: { id: mediaId }, |
| 71 | + }), |
| 72 | + }); |
| 73 | + |
| 74 | + if (!res.ok) { |
| 75 | + const err = await res.json(); |
| 76 | + throw new Error(`[whatsapp-voice] send audio failed ${res.status}: ${JSON.stringify(err)}`); |
| 77 | + } |
| 78 | + |
| 79 | + const data = await res.json(); |
| 80 | + return data.messages?.[0]?.id ?? ''; |
| 81 | +} |
| 82 | + |
| 83 | +export interface VoiceNoteResult { |
| 84 | + messageId: string; |
| 85 | + success: boolean; |
| 86 | + /** 'sent' = live voice note, 'skipped' = feature disabled / demo mode */ |
| 87 | + status: 'sent' | 'skipped' | 'error'; |
| 88 | + error?: string; |
| 89 | +} |
| 90 | + |
| 91 | +/** |
| 92 | + * High-level helper: synthesise `text` as a voice note and deliver it via |
| 93 | + * WhatsApp to `to`. In demo mode or when either ElevenLabs or WhatsApp |
| 94 | + * credentials are absent, the call is a no-op (returns { status: 'skipped' }). |
| 95 | + */ |
| 96 | +export async function sendVoiceNote( |
| 97 | + to: string, |
| 98 | + text: string, |
| 99 | + voiceId?: string, |
| 100 | +): Promise<VoiceNoteResult> { |
| 101 | + // --- Guard: demo mode --- |
| 102 | + if (isDemoMode()) { |
| 103 | + demoLog('[whatsapp-voice] skipped — demo mode', { to }); |
| 104 | + return { messageId: '', success: true, status: 'skipped' }; |
| 105 | + } |
| 106 | + |
| 107 | + // --- Guard: dependencies --- |
| 108 | + if (!elevenLabsEnabled()) { |
| 109 | + console.warn('[whatsapp-voice] skipped — ElevenLabs not configured'); |
| 110 | + return { messageId: '', success: true, status: 'skipped' }; |
| 111 | + } |
| 112 | + if (!whatsappEnabled()) { |
| 113 | + console.warn('[whatsapp-voice] skipped — WhatsApp credentials missing'); |
| 114 | + return { messageId: '', success: true, status: 'skipped' }; |
| 115 | + } |
| 116 | + |
| 117 | + const phoneNumberId = process.env.WHATSAPP_PHONE_NUMBER_ID!; |
| 118 | + const accessToken = (process.env.WHATSAPP_ACCESS_TOKEN || process.env.WHATSAPP_API_TOKEN)!; |
| 119 | + |
| 120 | + try { |
| 121 | + // 1. Synthesise audio |
| 122 | + const audioBuffer = await synthesise(text, voiceId); |
| 123 | + |
| 124 | + // 2. Upload to WhatsApp Media API |
| 125 | + const mediaId = await uploadAudio(audioBuffer, phoneNumberId, accessToken); |
| 126 | + |
| 127 | + // 3. Send audio message |
| 128 | + const messageId = await sendAudioMessage(to, mediaId, phoneNumberId, accessToken); |
| 129 | + |
| 130 | + console.log(`[whatsapp-voice] sent voice note to ${to}, messageId=${messageId}`); |
| 131 | + return { messageId, success: true, status: 'sent' }; |
| 132 | + } catch (err) { |
| 133 | + const message = err instanceof Error ? err.message : String(err); |
| 134 | + console.error('[whatsapp-voice] error:', message); |
| 135 | + return { messageId: '', success: false, status: 'error', error: message }; |
| 136 | + } |
| 137 | +} |
0 commit comments