Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 73 additions & 22 deletions sources/app/api/routes/voiceRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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}`);

Expand All @@ -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'
});
Expand All @@ -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}`,
{
Expand All @@ -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,
Expand Down