Skip to content

Commit 37a1058

Browse files
authored
feat(voice): add whatsapp-voice.service.ts — TTS → WhatsApp voice note delivery
This service synthesizes a WhatsApp message body as an audio voice note using ElevenLabs, uploads it to the WhatsApp Media API, and sends it as an audio message. It includes error handling and checks for demo mode and configuration.
1 parent e9a7689 commit 37a1058

1 file changed

Lines changed: 137 additions & 0 deletions

File tree

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
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

Comments
 (0)