// netlify/functions/chat.js
// Two jobs: talk to the tutor (Anthropic), and speak the reply out loud (ElevenLabs).
const SUPABASE_URL = (process.env.SUPABASE_URL || '').replace(/\/+$/, '');
const ANON_KEY = process.env.SUPABASE_ANON_KEY;
const SERVICE_KEY = process.env.SUPABASE_SERVICE_KEY;
const FREE_DAILY_MESSAGES = 60; // safety ceiling; the 5-conversation limit lives in the UI
const MAX_TTS_CHARS = 2000;
const MAX_HISTORY = 30;
// The language pair lives in the database, not in this file. Adding a language
// is one line here plus one entry in the picker on the front end.
const LANGUAGES = {
en: 'English',
es: 'Spanish',
pt: 'Portuguese',
fr: 'French',
it: 'Italian',
de: 'German',
nl: 'Dutch',
pl: 'Polish',
sv: 'Swedish',
da: 'Danish',
no: 'Norwegian',
fi: 'Finnish',
cs: 'Czech',
sk: 'Slovak',
hu: 'Hungarian',
ro: 'Romanian',
bg: 'Bulgarian',
hr: 'Croatian',
el: 'Greek',
uk: 'Ukrainian',
ru: 'Russian',
tr: 'Turkish',
ar: 'Arabic',
hi: 'Hindi',
ta: 'Tamil',
ja: 'Japanese',
ko: 'Korean',
zh: 'Chinese',
id: 'Indonesian',
ms: 'Malay',
fil: 'Filipino',
vi: 'Vietnamese'
};
const langName = code => LANGUAGES[code]?.name || LANGUAGES.en.name;
const json = (statusCode, payload) => ({
statusCode,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
async function getUser(event) {
const header = event.headers.authorization || event.headers.Authorization || '';
const token = header.startsWith('Bearer ') ? header.slice(7) : null;
if (!token || !SUPABASE_URL) return null;
try {
const res = await fetch(`${SUPABASE_URL}/auth/v1/user`, {
headers: { apikey: ANON_KEY, Authorization: `Bearer ${token}` }
});
if (!res.ok) {
console.error('Auth check rejected:', res.status, await res.text());
return null;
}
const user = await res.json();
return user && user.id ? user : null;
} catch (e) {
console.error('Token check failed:', e);
return null;
}
}
// Plan and language pair come from the same row, so one query covers both.
async function getProfile(userId) {
try {
const res = await fetch(
`${SUPABASE_URL}/rest/v1/users?id=eq.${userId}&select=plan,learning_language,native_language`,
{ headers: { apikey: SERVICE_KEY } }
);
const row = (await res.json())[0] || {};
return {
plan: (row.plan || 'free').toLowerCase(),
learning: row.learning_language || 'en',
native: row.native_language || 'es'
};
} catch (e) {
return { plan: 'free', learning: 'en', native: 'es' };
}
}
// Atomic counter, one row per user per day.
async function bumpUsage(userId) {
try {
const res = await fetch(`${SUPABASE_URL}/rest/v1/rpc/bump_usage`, {
method: 'POST',
headers: {
apikey: SERVICE_KEY,
'Content-Type': 'application/json'
},
body: JSON.stringify({ p_user: userId })
});
return (await res.json()) || 0;
} catch (e) {
return 0;
}
}
exports.handler = async function (event) {
if (event.httpMethod !== 'POST') {
return { statusCode: 405, body: 'Method Not Allowed' };
}
const user = await getUser(event);
if (!user) return json(401, { error: 'Sign in to keep practicing.' });
let body;
try {
body = JSON.parse(event.body);
} catch (e) {
return json(400, { error: 'Malformed request body.' });
}
const { messages, tutorName, tutorDesc, tts, mode } = body;
const profile = await getProfile(user.id);
const plan = profile.plan;
const target = langName(profile.learning); // what they are learning
const native = langName(profile.native); // what they already speak
/* ---------- ElevenLabs: speak the reply ---------- */
if (tts) {
// Voice is a paid feature, and night audio is Premium only. Without this
// check anyone could burn through the ElevenLabs credits.
if (plan === 'free') {
return json(402, { error: 'Voice conversations are included in Pro.', upgrade: true });
}
if (mode === 'night' && plan !== 'premium') {
return json(402, { error: 'Night audio is included in Premium.', upgrade: true });
}
if (typeof tts !== 'string' || tts.length > MAX_TTS_CHARS) {
return json(400, { error: 'That text is too long to read aloud.' });
}
const voiceIds = {
Leo: process.env.ELEVENLABS_VOICE_LEO,
Maya: process.env.ELEVENLABS_VOICE_MAYA,
Nova: process.env.ELEVENLABS_VOICE_NOVA
};
const voiceId = voiceIds[tutorName] || voiceIds.Maya;
try {
const response = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${voiceId}`, {
method: 'POST',
headers: {
'xi-api-key': process.env.ELEVENLABS_API_KEY,
'Content-Type': 'application/json'
},
body: JSON.stringify({
text: tts,
// Flash v2.5 costs 0.5 credits per character instead of 1, and it is
// still multilingual. ElevenLabs recommends it over Turbo.
model_id: 'eleven_flash_v2_5',
voice_settings: {
stability: 0.6,
similarity_boost: 0.85,
speed: 0.82,
use_speaker_boost: true
}
})
});
if (!response.ok) {
console.error('ElevenLabs error:', response.status, await response.text());
return json(502, { error: 'The voice service did not respond. Try again.' });
}
const audioBuffer = await response.arrayBuffer();
return json(200, { audio: Buffer.from(audioBuffer).toString('base64') });
} catch (error) {
console.error('TTS error:', error);
return json(500, { error: 'The voice service did not respond. Try again.' });
}
}
/* ---------- Anthropic: the tutor replies ---------- */
if (!Array.isArray(messages) || messages.length === 0) {
return json(400, { error: 'No message to answer.' });
}
const used = await bumpUsage(user.id);
if (plan === 'free' && used > FREE_DAILY_MESSAGES) {
return json(402, {
error: 'You have reached the free daily limit. Come back tomorrow or upgrade.',
upgrade: true
});
}
const systemPrompt = `You are ${tutorName}, a friendly bilingual ${target} tutor on Talkova. Your personality: ${tutorDesc}
The learner speaks ${native} and is learning ${target}.
Rules:
- Respond warmly and encouragingly. Never make the learner feel bad.
- When the user makes ${target} mistakes, gently correct them: ❌ [wrong] → ✅ [correct] — explain WHY briefly.
- If they write in ${native}, respond in ${native} AND show them how to say it in ${target}.
- Keep responses short: 2–4 sentences. Always end with a follow-up question to keep the conversation going.
- Feel like a supportive bilingual friend, not a textbook.
IMPORTANT: At the end of every response, add a JSON block (hidden from display) with this exact format:
`;
try {
const response = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': process.env.ANTHROPIC_API_KEY,
'anthropic-version': '2023-06-01'
},
body: JSON.stringify({
model: 'claude-sonnet-4-6',
max_tokens: 1024,
system: systemPrompt,
messages: messages.slice(-MAX_HISTORY)
})
});
const data = await response.json();
if (!response.ok || !data.content) {
console.error('Anthropic error:', data);
return json(502, { error: 'The tutor is not responding. Try again in a moment.' });
}
const fullText = data.content[0].text;
const progressMatch = fullText.match(/