diff --git a/sources/app/api/routes/voiceRoutes.ts b/sources/app/api/routes/voiceRoutes.ts index ddc2842..68b1e07 100644 --- a/sources/app/api/routes/voiceRoutes.ts +++ b/sources/app/api/routes/voiceRoutes.ts @@ -2,13 +2,24 @@ import { z } from "zod"; import { type Fastify } from "../types"; import { log } from "@/utils/log"; +/** + * Voice routes for ElevenLabs Conversational AI integration. + * + * Supports two modes: + * 1. Server credentials (default): Uses ELEVENLABS_API_KEY and ELEVENLABS_AGENT_ID env vars + * 2. User credentials: Client provides customAgentId and customApiKey in the request + * + * In development mode (ENV=dev), RevenueCat subscription check is skipped. + */ export function voiceRoutes(app: Fastify) { app.post('/v1/voice/token', { preHandler: app.authenticate, schema: { body: z.object({ - agentId: z.string(), - revenueCatPublicKey: z.string().optional() + revenueCatPublicKey: z.string().optional(), + // Custom ElevenLabs credentials (when user provides their own) + customAgentId: z.string().optional(), + customApiKey: z.string().optional() }), response: { 200: z.object({ @@ -24,7 +35,7 @@ export function voiceRoutes(app: Fastify) { } }, async (request, reply) => { const userId = request.userId; // CUID from JWT - const { agentId, revenueCatPublicKey } = request.body; + const { revenueCatPublicKey, customAgentId, customApiKey } = request.body; log({ module: 'voice' }, `Voice token request from user ${userId}`); @@ -33,7 +44,7 @@ export function voiceRoutes(app: Fastify) { // Production requires RevenueCat key if (!isDevelopment && !revenueCatPublicKey) { log({ module: 'voice' }, 'Production environment requires RevenueCat public key'); - return reply.code(400).send({ + return reply.code(400).send({ allowed: false, error: 'RevenueCat public key required' }); @@ -54,32 +65,50 @@ export function voiceRoutes(app: Fastify) { if (!response.ok) { log({ module: 'voice' }, `RevenueCat check failed for user ${userId}: ${response.status}`); - return reply.send({ - allowed: false, - agentId + return reply.send({ + allowed: false }); } const data = await response.json() as any; const proEntitlement = data.subscriber?.entitlements?.active?.pro; - + if (!proEntitlement) { log({ module: 'voice' }, `User ${userId} does not have active subscription`); - return reply.send({ - allowed: false, - agentId + return reply.send({ + allowed: false }); } } - // Check if 11Labs API key is configured - const elevenLabsApiKey = process.env.ELEVENLABS_API_KEY; - if (!elevenLabsApiKey) { - log({ module: 'voice' }, 'Missing 11Labs API key'); - return reply.code(400).send({ allowed: false, error: 'Missing 11Labs API key on the server' }); + // Determine which credentials to use: user-provided or server defaults + const useCustomCredentials = customAgentId && customApiKey; + + let elevenLabsApiKey: string | undefined; + let agentId: string | undefined; + + if (useCustomCredentials) { + // User provided their own ElevenLabs credentials + log({ module: 'voice' }, `Using custom ElevenLabs credentials for user ${userId}`); + elevenLabsApiKey = customApiKey; + agentId = customAgentId; + } else { + // Use server's default credentials + elevenLabsApiKey = process.env.ELEVENLABS_API_KEY; + agentId = process.env.ELEVENLABS_AGENT_ID; + + if (!elevenLabsApiKey) { + log({ module: 'voice' }, 'Missing ELEVENLABS_API_KEY environment variable'); + return reply.code(400).send({ allowed: false, error: 'Voice not configured on server (missing API key)' }); + } + + if (!agentId) { + log({ module: 'voice' }, 'Missing ELEVENLABS_AGENT_ID environment variable'); + return reply.code(400).send({ allowed: false, error: 'Voice not configured on server (missing agent ID)' }); + } } - // Get 11Labs conversation token + // Get 11Labs conversation token (for WebRTC connections) const response = await fetch( `https://api.elevenlabs.io/v1/convai/conversation/token?agent_id=${agentId}`, { @@ -90,19 +119,41 @@ export function voiceRoutes(app: Fastify) { } } ); - + if (!response.ok) { - log({ module: 'voice' }, `Failed to get 11Labs token for user ${userId}`); - return reply.code(400).send({ + const errorText = await response.text(); + log({ module: 'voice' }, `Failed to get 11Labs token: ${response.status} ${errorText}`); + + // Parse error for better user feedback + let errorDetail = 'Failed to get voice token from ElevenLabs'; + try { + const errorJson = JSON.parse(errorText); + if (errorJson.detail?.message) { + errorDetail = errorJson.detail.message; + } else if (errorJson.detail?.status) { + errorDetail = `ElevenLabs error: ${errorJson.detail.status}`; + } + } catch { + // Use default error message + } + + return reply.code(400).send({ allowed: false, - error: `Failed to get 11Labs token for user ${userId}` + error: errorDetail }); } const data = await response.json() as any; - console.log(data); const token = data.token; + if (!token) { + log({ module: 'voice' }, 'ElevenLabs returned empty token'); + return reply.code(400).send({ + allowed: false, + error: 'ElevenLabs returned invalid response' + }); + } + log({ module: 'voice' }, `Voice token issued for user ${userId}`); return reply.send({ allowed: true,