import { serve } from "https://deno.land/std@0.168.0/http/server.ts"; import { createClient } from "https://esm.sh/@supabase/supabase-js@2.39.7"; import { GoogleGenerativeAI } from "https://esm.sh/@google/generative-ai@0.21.0"; import { SYSTEM_PROMPT, COACH_SYSTEM_PROMPT } from "./prompt.ts"; import { buildCoachPdfHtml } from "./pdf-template.ts"; // ─── Config ──────────────────────────────────────────────────────── const SUPABASE_URL = Deno.env.get("SUPABASE_URL") as string; const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") as string; const GEMINI_API_KEY = Deno.env.get("GEMINI_API_KEY") as string; const META_VERIFY_TOKEN = Deno.env.get("META_VERIFY_TOKEN") as string; const META_ACCESS_TOKEN = Deno.env.get("META_ACCESS_TOKEN") as string; const META_PHONE_NUMBER_ID = Deno.env.get("META_PHONE_NUMBER_ID") as string; const IMAGE_RENDERER_URL = Deno.env.get("IMAGE_RENDERER_URL"); const GRAPH_API_URL = "https://graph.facebook.com/v19.0"; const FREE_FOOD_LIMIT = 5; // ─── Types ───────────────────────────────────────────────────────── interface MetaWebhookPayload { object: string; entry?: { id: string; changes?: { value?: { messaging_product: string; metadata?: { display_phone_number: string; phone_number_id: string }; contacts?: { profile: { name: string }; wa_id: string }[]; messages?: { from: string; id: string; timestamp: string; type: string; text?: { body: string }; button?: { payload: string, text: string }; }[]; statuses?: { id: string; status: string; timestamp: string; recipient_id: string; errors?: { code: number; title: string; message: string; error_data?: { details: string }; }[]; }[]; }; field: string; }[]; }[]; } // ─── Helpers ─────────────────────────────────────────────────────── /** Remove tudo que não é dígito */ const onlyDigits = (s: string) => s.replace(/\D/g, ""); /** * Gera candidatos de número brasileiro (com/sem DDI 55, com/sem 9º dígito). * Usado para fazer match com profiles.phone_e164 e profiles.phone. */ function generatePhoneCandidates(raw: string): string[] { const candidates: string[] = []; const num = onlyDigits(raw); if (!num) return candidates; candidates.push(num); const withoutDDI = num.startsWith("55") ? num.slice(2) : num; if (withoutDDI !== num) candidates.push(withoutDDI); if (!num.startsWith("55")) candidates.push("55" + num); const ddd = withoutDDI.slice(0, 2); const rest = withoutDDI.slice(2); // Adiciona 9º dígito se tem 8 dígitos após DDD if (rest.length === 8) { const with9 = ddd + "9" + rest; candidates.push(with9); candidates.push("55" + with9); } // Remove 9º dígito se tem 9 dígitos após DDD if (rest.length === 9 && rest.startsWith("9")) { const without9 = ddd + rest.slice(1); candidates.push(without9); candidates.push("55" + without9); } return candidates; } /** Envia mensagem de texto via Meta Cloud API */ async function sendWhatsAppMessage(remoteJid: string, text: string) { if (!META_ACCESS_TOKEN || !META_PHONE_NUMBER_ID) return; try { const url = `${GRAPH_API_URL}/${META_PHONE_NUMBER_ID}/messages`; const payload = { messaging_product: "whatsapp", recipient_type: "individual", to: remoteJid, type: "text", text: { preview_url: false, body: text } }; const res = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${META_ACCESS_TOKEN}` }, body: JSON.stringify(payload) }); const resBody = await res.text(); console.log(`[META-WH] SendText status: ${res.status}`); } catch (err) { console.error("[META-WH] Error sending text:", err); } } /** Envia mensagem interativa CTA via Meta Cloud API */ async function sendWhatsAppInteractiveMessage(remoteJid: string, text: string, buttonText: string, linkUrl: string) { if (!META_ACCESS_TOKEN || !META_PHONE_NUMBER_ID) return; try { const url = `${GRAPH_API_URL}/${META_PHONE_NUMBER_ID}/messages`; const payload = { messaging_product: "whatsapp", recipient_type: "individual", to: remoteJid, type: "interactive", interactive: { type: "cta_url", body: { text: text }, footer: { text: "FoodSnap PRO" }, action: { name: "cta_url", parameters: { display_text: buttonText, url: linkUrl } } } }; const res = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${META_ACCESS_TOKEN}` }, body: JSON.stringify(payload) }); if (!res.ok) { await sendWhatsAppMessage(remoteJid, `${text}\n\n👉 Acesse: ${linkUrl}`); } } catch (err) { await sendWhatsAppMessage(remoteJid, `${text}\n\n👉 Acesse: ${linkUrl}`); } } /** Envia botões de resposta rápida via Meta Cloud API (Máx 3 botões) */ async function sendWhatsAppInteractiveButtons(remoteJid: string, text: string, buttons: { id: string, title: string }[]) { if (!META_ACCESS_TOKEN || !META_PHONE_NUMBER_ID) return; try { const url = `${GRAPH_API_URL}/${META_PHONE_NUMBER_ID}/messages`; const payload = { messaging_product: "whatsapp", recipient_type: "individual", to: remoteJid, type: "interactive", interactive: { type: "button", body: { text: text }, action: { buttons: buttons.map(b => ({ type: "reply", reply: { id: b.id, title: b.title.substring(0, 20) } })) } } }; const res = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${META_ACCESS_TOKEN}` }, body: JSON.stringify(payload) }); console.log(`[META-WH] SendButtons status: ${res.status}`); } catch (err) { console.error("[META-WH] Error sending buttons:", err); } } /** Envia documento (PDF) via Meta Cloud API (Link) */ async function sendWhatsAppDocument(remoteJid: string, mediaUrl: string, fileName: string, caption?: string) { if (!META_ACCESS_TOKEN || !META_PHONE_NUMBER_ID) return; try { const url = `${GRAPH_API_URL}/${META_PHONE_NUMBER_ID}/messages`; const payload = { messaging_product: "whatsapp", recipient_type: "individual", to: remoteJid, type: "document", document: { link: mediaUrl, filename: fileName, caption: caption || "" } }; const res = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${META_ACCESS_TOKEN}` }, body: JSON.stringify(payload) }); console.log(`[META-WH] SendDoc status: ${res.status}`); } catch (err) { console.error("[META-WH] Error sending document:", err); } } /** Envia imagem real via Meta Cloud API (Link) */ async function sendWhatsAppImage(remoteJid: string, imageUrl: string, caption?: string) { if (!META_ACCESS_TOKEN || !META_PHONE_NUMBER_ID) return; try { const url = `${GRAPH_API_URL}/${META_PHONE_NUMBER_ID}/messages`; const payload = { messaging_product: "whatsapp", recipient_type: "individual", to: remoteJid, type: "image", image: { link: imageUrl, caption: caption || "" } }; const res = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${META_ACCESS_TOKEN}` }, body: JSON.stringify(payload) }); console.log(`[META-WH] SendImage status: ${res.status}`); } catch (err) { console.error("[META-WH] Error sending image:", err); } } /** Busca imagem em base64 da Meta API através do Media ID */ async function getWhatsAppMedia(mediaId: string): Promise { if (!META_ACCESS_TOKEN) return null; try { // Passo 1: Obter URL da mídia const urlRes = await fetch(`${GRAPH_API_URL}/${mediaId}`, { headers: { Authorization: `Bearer ${META_ACCESS_TOKEN}` } }); if (!urlRes.ok) return null; const urlData = await urlRes.json(); const mediaUrl = urlData.url; // Passo 2: Baixar a mídia real como binário const mediaRes = await fetch(mediaUrl, { headers: { Authorization: `Bearer ${META_ACCESS_TOKEN}` } }); if (!mediaRes.ok) return null; const arrayBuffer = await mediaRes.arrayBuffer(); const buffer = new Uint8Array(arrayBuffer); // Convert to Base64 in chunks to avoid call stack limits let binary = ''; const len = buffer.byteLength; for (let i = 0; i < len; i++) { binary += String.fromCharCode(buffer[i]); } return btoa(binary); } catch (err) { console.error("[META-WH] Error fetching media:", err); return null; } } /** Converte base64 → Uint8Array (para upload storage) */ function base64ToUint8Array(base64: string): Uint8Array { const bin = atob(base64); const bytes = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); return bytes; } // ─── Geração de HTML para PDF do Coach ──────────────────────────── // (Movido para pdf-template.ts) // ─── Normalização e limpeza do JSON do Gemini (portado do n8n) ──── const toNum = (v: unknown): number => { if (typeof v === "number") return v; if (typeof v === "string") { const n = Number(v.replace(",", ".").trim()); return Number.isFinite(n) ? n : 0; } return 0; }; const ensureArray = (v: unknown): any[] => (Array.isArray(v) ? v : []); const keyName = (s: string) => (s || "") .trim() .toLowerCase() .normalize("NFD") .replace(/[\u0300-\u036f]/g, ""); const clampConfidence = (c: string) => { const k = keyName(c); if (k.includes("alta")) return "alta"; if (k.includes("baixa")) return "baixa"; return "media"; }; const CITRUS_VARIANTS = /^(tangerina|bergamota|mandarina|clementina|mexerica)/; const CANONICAL_MAP = [ { match: /^laranja/, canonical: "Laranja" }, { match: /^banana/, canonical: "Banana" }, { match: /^maca|^maçã/, canonical: "Maçã" }, { match: /^pera/, canonical: "Pera" }, { match: /^uva/, canonical: "Uva" }, { match: /^abacaxi/, canonical: "Abacaxi" }, { match: /^melancia/, canonical: "Melancia" }, { match: /^melao|^melão/, canonical: "Melão" }, ]; function canonicalizeName(name: string): string { const k = keyName(name); if (CITRUS_VARIANTS.test(k)) return "Laranja"; for (const rule of CANONICAL_MAP) { if (rule.match.test(k)) return rule.canonical; } return (name || "").trim(); } const stripCitrusMention = (s: string) => { const k = keyName(s); if (/(tangerina|bergamota|mandarina|clementina|mexerica)/.test(k)) { return s .replace(/tangerina\/bergamota/gi, "laranja") .replace(/tangerina|bergamota|mandarina|clementina|mexerica/gi, "laranja") .trim(); } return s; }; const parseUnitsPortion = (portion: string) => { const p = (portion || "").toLowerCase().replace(",", "."); const um = p.match(/(\d+)\s*unidades?/); const g = p.match(/(\d+(\.\d+)?)\s*g/); return { units: um ? Number(um[1]) : null, grams: g ? Math.round(Number(g[1])) : null, }; }; const buildUnitsPortion = (units: number | null, grams: number | null) => { const u = units && units > 0 ? units : null; const g = grams && grams > 0 ? grams : null; if (u && g) return `${u} unidades (${g}g)`; if (u) return `${u} unidades`; if (g) return `${g}g`; return ""; }; /** * Recebe o texto cru do Gemini e retorna o objeto normalizado * (portado do nó "Limpar Resultado" do n8n) */ function parseAndCleanGeminiResponse(rawText: string): any { // Limpa markdown let cleaned = rawText.replace(/```json/gi, "").replace(/```/g, "").trim(); // Extrai JSON const m = cleaned.match(/\{[\s\S]*\}/); if (!m) throw new Error("JSON não encontrado na resposta do Gemini."); let jsonStr = m[0]; // Corrige JSON mal formado jsonStr = jsonStr.replace(/:\s*\+(\d+(\.\d+)?)/g, ": $1"); jsonStr = jsonStr.replace(/,\s*([}\]])/g, "$1"); const parsed = JSON.parse(jsonStr); // Normaliza items parsed.items = ensureArray(parsed.items).map((it: any) => { const rawName = (it.name || "").trim(); const k = keyName(rawName); const flags = ensureArray(it.flags); const name = canonicalizeName(rawName); const nextFlags = CITRUS_VARIANTS.test(k) ? Array.from(new Set([...flags, "tipo_duvidoso"])) : flags; return { ...it, name, portion: (it.portion || "").trim(), calories: toNum(it.calories), protein: toNum(it.protein), carbs: toNum(it.carbs), fat: toNum(it.fat), fiber: toNum(it.fiber), sugar: toNum(it.sugar), sodium_mg: toNum(it.sodium_mg), flags: nextFlags, }; }); // Deduplica por nome const byName = new Map(); for (const it of parsed.items) { const k = keyName(it.name); if (!k) continue; if (!byName.has(k)) { byName.set(k, it); continue; } const cur = byName.get(k); const a = parseUnitsPortion(cur.portion); const b = parseUnitsPortion(it.portion); let mergedPortion = cur.portion; if (a.units !== null || b.units !== null || a.grams !== null || b.grams !== null) { const units = (a.units || 0) + (b.units || 0); const grams = (a.grams || 0) + (b.grams || 0); const rebuilt = buildUnitsPortion(units || null, grams || null); if (rebuilt) mergedPortion = rebuilt; } byName.set(k, { ...cur, portion: mergedPortion, calories: toNum(cur.calories) + toNum(it.calories), protein: toNum(cur.protein) + toNum(it.protein), carbs: toNum(cur.carbs) + toNum(it.carbs), fat: toNum(cur.fat) + toNum(it.fat), fiber: toNum(cur.fiber) + toNum(it.fiber), sugar: toNum(cur.sugar) + toNum(it.sugar), sodium_mg: toNum(cur.sodium_mg) + toNum(it.sodium_mg), flags: Array.from( new Set([...ensureArray(cur.flags), ...ensureArray(it.flags), "deduplicado"]) ), }); } parsed.items = Array.from(byName.values()); // Recalcula totais const sum = (arr: any[], f: string) => arr.reduce((a: number, b: any) => a + toNum(b[f]), 0); parsed.total = { calories: Math.round(sum(parsed.items, "calories")), protein: +sum(parsed.items, "protein").toFixed(1), carbs: +sum(parsed.items, "carbs").toFixed(1), fat: +sum(parsed.items, "fat").toFixed(1), fiber: +sum(parsed.items, "fiber").toFixed(1), sugar: +sum(parsed.items, "sugar").toFixed(1), sodium_mg: Math.round(sum(parsed.items, "sodium_mg")), }; // Outros campos parsed.health_score = toNum(parsed.health_score); parsed.confidence = clampConfidence(parsed.confidence || ""); parsed.assumptions = ensureArray(parsed.assumptions).map(stripCitrusMention); parsed.questions = ensureArray(parsed.questions); parsed.insights = ensureArray(parsed.insights).map(stripCitrusMention); parsed.swap_suggestions = ensureArray(parsed.swap_suggestions); parsed.next_best_actions = ensureArray(parsed.next_best_actions); parsed.tip = parsed.tip && typeof parsed.tip === "object" ? parsed.tip : { title: "", text: "", reason: "" }; parsed.tip.title = String(parsed.tip.title || ""); parsed.tip.text = stripCitrusMention(String(parsed.tip.text || "")); parsed.tip.reason = stripCitrusMention(String(parsed.tip.reason || "")); return parsed; } /** * Formata a análise em mensagem rica para WhatsApp * (portado do nó "Formatar Resposta WHATS" do n8n) */ function formatWhatsAppResponse(analysis: any): string { if (!analysis || !Array.isArray(analysis.items) || !analysis.items.length) { return "⚠️ Não foi possível identificar um alimento válido na imagem. Tente uma foto com melhor iluminação ou de um ângulo diferente."; } const items = analysis.items; const total = analysis.total || {}; const fmt = (n: unknown) => { if (n === undefined || n === null || n === "") return "—"; const num = Number(n); if (!Number.isFinite(num)) return String(n); return (Math.round(num * 10) / 10).toString(); }; const v = (x: unknown) => (x === undefined || x === null || x === "" ? "—" : x); const lines: string[] = []; let scoreEmoji = "🟢"; // High score if (analysis.health_score !== undefined) { const score = Number(analysis.health_score); if (score < 50) scoreEmoji = "🔴"; else if (score < 80) scoreEmoji = "🟡"; } // PREMIUM LAYOUT lines.push("📱 *FOODSNAP ANALYTICS*"); lines.push(""); lines.push(`🔥 *CALORIAS:* ${fmt(total.calories)} kcal`); if (analysis.health_score !== undefined) { lines.push(`🏆 *SCORE:* ${fmt(analysis.health_score)}/100 ${scoreEmoji}`); } lines.push(""); lines.push("🧬 *MACROS*"); lines.push(`▪ *Proteína:* ${fmt(total.protein)}g`); lines.push(`▪ *Carboidrato:* ${fmt(total.carbs)}g`); lines.push(`▪ *Gordura:* ${fmt(total.fat)}g`); lines.push(""); lines.push("🥗 *O QUE ENCONTREI*"); items.forEach((it: any) => { lines.push(`• ${v(it.name)} (${v(it.portion)})`); }); lines.push(""); lines.push(`📌 *Fibras:* ${fmt(total.fiber)}g | *Açúcares:* ${fmt(total.sugar)}g | *Sódio:* ${fmt(total.sodium_mg)}mg`); if (analysis.insights && Array.isArray(analysis.insights) && analysis.insights.length > 0) { lines.push(""); lines.push("🔎 *VEREDITO:*"); analysis.insights.forEach((insight: string) => { lines.push(`• ${insight}`); }); } if (analysis.tip && analysis.tip.text) { lines.push(""); lines.push(`💡 _DICA:_ ${analysis.tip.text}`); } return lines.join("\n"); } // ─── Main Handler ────────────────────────────────────────────────── serve(async (req) => { // ── 0. Verificação do Webhook (GET) ─────────── if (req.method === "GET") { const url = new URL(req.url); const mode = url.searchParams.get("hub.mode"); const token = url.searchParams.get("hub.verify_token"); const challenge = url.searchParams.get("hub.challenge"); if (mode === "subscribe" && token === META_VERIFY_TOKEN) { console.log("[META-WH] Webhook concluído e certificado pela Meta!"); return new Response(challenge, { status: 200 }); } else { return new Response("Forbidden", { status: 403 }); } } if (req.method !== "POST") { return new Response("Method not allowed", { status: 405 }); } try { const payload: MetaWebhookPayload = await req.json(); if (payload.object !== "whatsapp_business_account") { return new Response("Ignored", { status: 200 }); } const entry = payload.entry?.[0]; const changes = entry?.changes?.[0]; const value = changes?.value; const messages = value?.messages; const statuses = value?.statuses; // Se for apenas status lido/entregue, ignora if (!messages || !messages[0]) { if (statuses && statuses[0]?.status === "failed") { console.error("[META-WH] MESSAGE DELIVERY FAILED:", JSON.stringify(statuses[0].errors)); } else if (statuses) { console.log("[META-WH] Message Status Update:", statuses[0]?.status); } return new Response("No message to process", { status: 200 }); } // --- ASYNC PROCESS TO PREVENT META 3s TIMEOUT --- // // A Meta precisa que respondamos 200 OK quase imediatamente. // Se a gente prender o hook chamando a API do Gemini, // a Meta acha que falhou e manda DE NOVO, duplicando a mensagem. // Então rodamos o bloco principal em background no Node (Promise flutuante), // sem usar "await" na borda. processMetaMessage(messages[0]); return new Response("Processing started", { status: 200 }); } catch (err) { console.error("[WH] Fatal error", err); return new Response("Internal Server Error", { status: 500 }); } }); // ─── LÓGICA ASSÍNCRONA DESACOPLADA (Background) ────────────────────── async function processMetaMessage(msg: any) { try { const remoteJid = msg.from; // Formato puro sem @s.whatsapp.net // ── 1. Extrair dados ──────────────────────────────────────── const senderNumber = onlyDigits(remoteJid); const senderFromPayload = ""; // Em vez de salvar o ID da mensagem como texto, salvamos o Media ID se for imagem const messageId = msg.image?.id || msg.id; const isImage = msg.type === "image"; let textMessage = ""; let interactiveId = ""; if (msg.type === "text") textMessage = msg.text?.body || ""; if (msg.type === "button") textMessage = msg.button?.text || ""; if (msg.type === "interactive") { interactiveId = msg.interactive?.button_reply?.id || msg.interactive?.list_reply?.id || ""; textMessage = msg.interactive?.button_reply?.title || msg.interactive?.list_reply?.title || ""; } console.log(`[WH] sender=${senderNumber}, isImage=${isImage}, text="${textMessage.slice(0, 50)}", id="${interactiveId}"`); // Gerar candidatos de número BR const allCandidates = [ ...generatePhoneCandidates(senderNumber), ...(senderFromPayload ? generatePhoneCandidates(senderFromPayload) : []), ]; const phoneCandidates = [...new Set(allCandidates)]; console.log(`[WH] phoneCandidates: ${JSON.stringify(phoneCandidates)}`); // ── 2. Init Supabase ──────────────────────────────────────── const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY); // ── 3. Buscar usuário com phone_candidates ────────────────── let user: { id: string, current_streak?: number, longest_streak?: number, last_scan_date?: string } | null = null; for (const candidate of phoneCandidates) { const { data: directMatch, error: matchErr } = await supabase .from("profiles") .select("id, current_streak, longest_streak, last_scan_date") .or(`phone_e164.eq.${candidate},phone.eq.${candidate}`) .maybeSingle(); if (matchErr) { console.error(`[WH] DB error matching candidate ${candidate}:`, matchErr.message); } if (directMatch) { user = directMatch; console.log(`[WH] User found: ${user.id} (matched candidate: ${candidate})`); break; } } if (!user) { console.log(`[WH] User NOT found for candidates: ${phoneCandidates.join(", ")}`); await sendWhatsAppInteractiveMessage( remoteJid, "🚫 *Seu número não está cadastrado no FoodSnap*.\n\nPara usar a inteligência artificial via WhatsApp, você precisa ter uma conta ativa e o seu celular atualizado no perfil.", "📲 Criar Conta Grátis", "https://foodsnap.com.br" ); return; } const userId = user.id; // ── 4. Estado da conversa (Coach state machine) ───────────── let { data: conv } = await supabase .from("whatsapp_conversations") .select("*") .eq("phone_number", senderNumber) .maybeSingle(); // ── 3.5 Prevenir Mensagens Duplicadas (Retries da Meta) ────── if (conv && conv.last_msg_id === messageId) { console.log(`[WH] Repeated message ignored: ${messageId}`); return; } // ── 4. Atualizar Contexto (Upsert) ────────────────────────── if (!conv) { const { data: newConv } = await supabase .from("whatsapp_conversations") .insert({ phone_number: senderNumber, contact_name: senderFromPayload, state: "IDLE", temp_data: {}, last_msg_id: messageId }) .select() .single(); conv = newConv; console.log(`[WH] New conversation for ${senderNumber}`); } else { // Atualizar o last_msg_id mesmo se não mudarmos o state agora await supabase .from("whatsapp_conversations") .update({ last_msg_id: messageId, updated_at: new Date().toISOString() }) .eq("id", conv.id); } const state = conv?.state || "IDLE"; console.log(`[WH] Conversation state: ${state}, conv exists: ${!!conv}`); // ── 5. Coach e Recomendações ──────────────────────────────── // TRIGGER: action_recommend (O que mais comer?) if (state === "IDLE" && (interactiveId === "action_recommend" || /oque comer|o que comer|recomenda[çc]/i.test(textMessage))) { const lastAnalysisText = conv?.temp_data?.last_analysis; if (!lastAnalysisText) { await sendWhatsAppMessage(remoteJid, "Por favor, envie a foto de um prato primeiro."); return; } await sendWhatsAppMessage(remoteJid, "🧠 Analisando possíveis combinações..."); try { const genAI = new GoogleGenerativeAI(GEMINI_API_KEY); const model = genAI.getGenerativeModel({ model: "gemini-2.5-flash" }); const prompt = `Atue como um Nutricionista Clínico Premium de Elite. Aja com tom direto, profissional e direto ao ponto. Sem "Oi" nem "Que bom". O paciente comeu: ${lastAnalysisText} RETORNE estritamente 3 bullet points recomendando o que o paciente pode adicionar a esta refeição para otimizar os MACROS (com foco em saciedade ou perfil proteico). Destaque em negrito os alimentos que está sugerindo. Seja extremamente conciso. Ex: • Adicione 1 dose de *Whey Protein* (faltou proteína) • Inclua 1 porção de *Pasta de Amendoim* (faltou gordura boa) `; const result = await model.generateContent({ contents: [{ role: "user", parts: [{ text: prompt }] }], generationConfig: { temperature: 0.2 } }); const recommendation = result.response.text(); await sendWhatsAppMessage(remoteJid, `💡 *COMPLEMENTO RECOMENDADO:*\n\n${recommendation}`); } catch (err) { console.error("Error generating recommendation", err); await sendWhatsAppMessage(remoteJid, "Tive um problema de comunicação ao buscar dicas."); } return; } // TRIGGER: texto contendo palavras-chave coach if ( state === "IDLE" && (interactiveId === "action_coach" || (textMessage && /coach|treino|avalia[çc][aã]o/i.test(textMessage))) ) { // Offload long-running task to background setTimeout(async () => { // [STRICT VALIDATION] Check for active PAID plan const { data: entitlement } = await supabase .from("user_entitlements") .select("is_active, valid_until, entitlement_code") .eq("user_id", userId) .match({ is_active: true }) // Ensure active .maybeSingle(); const isPaid = entitlement && ['pro', 'mensal', 'trimestral', 'anual', 'trial', 'paid'].includes(entitlement.entitlement_code) && (!entitlement.valid_until || new Date(entitlement.valid_until) > new Date()); if (!isPaid) { await sendWhatsAppInteractiveMessage( remoteJid, "🔒 *Funcionalidade Exclusiva PRO*\n\nO *Personal Coach IA* está disponível apenas para assinantes Premium.\n\nCom o plano PRO você tem:\n✅ IA Analisadora de Físico (Fotos)\n✅ Treinos hiper-personalizados\n✅ Estratégia de Dieta com Macros", "⭐ Desbloquear Coach", "https://foodsnap.com.br" ); return; } // [LOGIC START] Verificar última avaliação (Limite de 7 dias) const { data: lastAnalysis } = await supabase .from("coach_analyses") .select("created_at") .eq("user_id", userId) .order("created_at", { ascending: false }) .limit(1) .maybeSingle(); if (lastAnalysis && lastAnalysis.created_at) { const lastDate = new Date(lastAnalysis.created_at); const now = new Date(); const diffTime = Math.abs(now.getTime() - lastDate.getTime()); const sevenDaysInMs = 7 * 24 * 60 * 60 * 1000; if (diffTime < sevenDaysInMs) { const daysRemaining = Math.ceil((sevenDaysInMs - diffTime) / (1000 * 60 * 60 * 24)); await sendWhatsAppMessage( remoteJid, `⏳ *Calma, atleta!* O corpo precisa de tempo para evoluir.\n\nSua última avaliação foi há menos de uma semana.\nVocê poderá fazer uma nova avaliação em *${daysRemaining} dia(s)*.\n\nFoque no plano atual! 💪` ); return; } } // [LOGIC END] await supabase .from("whatsapp_conversations") .update({ state: "COACH_FRONT", temp_data: {} }) .eq("phone_number", senderNumber); await sendWhatsAppMessage( remoteJid, "🏋️‍♂️ *INICIANDO AVALIAÇÃO COM IA* 🏋️‍♀️\n\nVamos montar seu plano.\nPara eu calcular seu biotipo e recomendar o treino perfeito, por favor envie uma *FOTO DE FRENTE*.\n\n_(Importante: camiseta colada ou sem camisa e bermuda. Procure boa iluminação)_" ); }, 0); return new Response("Coach Started", { status: 200 }); } // COACH_FRONT if (state === "COACH_FRONT") { if (!isImage) { setTimeout(async () => { await sendWhatsAppMessage(remoteJid, "⚠️ Por favor, envie a foto de *FRENTE* para continuarmos."); }, 0); return new Response("Waiting for image", { status: 200 }); } // Offload long-running task to background setTimeout(async () => { const base64 = await getWhatsAppMedia(messageId); if (!base64) { await sendWhatsAppMessage(remoteJid, "⚠️ Não consegui baixar a imagem. Tente enviar novamente."); return; } const fileName = `${userId}_front_${Date.now()}.jpg`; await supabase.storage .from("coach-uploads") .upload(fileName, base64ToUint8Array(base64), { contentType: "image/jpeg" }); await supabase .from("whatsapp_conversations") .update({ state: "COACH_SIDE", temp_data: { ...conv!.temp_data, front_image: fileName } }) .eq("phone_number", senderNumber); await sendWhatsAppMessage(remoteJid, "✅ Foto de frente recebida!\nAgora, envie uma foto de *LADO* (Perfil)."); }, 0); return new Response("Coach Front image received", { status: 200 }); } // COACH_SIDE if (state === "COACH_SIDE") { if (!isImage) { setTimeout(async () => { await sendWhatsAppMessage(remoteJid, "⚠️ Por favor, envie a foto de *LADO*."); }, 0); return new Response("Waiting for image", { status: 200 }); } // Offload long-running task to background setTimeout(async () => { const base64 = await getWhatsAppMedia(messageId); if (!base64) { await sendWhatsAppMessage(remoteJid, "⚠️ Não consegui baixar a imagem. Tente enviar novamente."); return; } const fileName = `${userId}_side_${Date.now()}.jpg`; await supabase.storage .from("coach-uploads") .upload(fileName, base64ToUint8Array(base64), { contentType: "image/jpeg" }); await supabase .from("whatsapp_conversations") .update({ state: "COACH_BACK", temp_data: { ...conv!.temp_data, side_image: fileName } }) .eq("phone_number", senderNumber); await sendWhatsAppMessage(remoteJid, "✅ Perfil recebido!\nPor último, envie uma foto de *COSTAS*."); }, 0); return new Response("Coach Side image received", { status: 200 }); } // COACH_BACK if (state === "COACH_BACK") { if (!isImage) { setTimeout(async () => { await sendWhatsAppMessage(remoteJid, "⚠️ Por favor, envie a foto de *COSTAS*."); }, 0); return new Response("Waiting for image", { status: 200 }); } // Offload long-running task to background setTimeout(async () => { const base64 = await getWhatsAppMedia(messageId); if (!base64) { await sendWhatsAppMessage(remoteJid, "⚠️ Não consegui baixar a imagem. Tente enviar novamente."); return; } const fileName = `${userId}_back_${Date.now()}.jpg`; await supabase.storage .from("coach-uploads") .upload(fileName, base64ToUint8Array(base64), { contentType: "image/jpeg" }); await supabase .from("whatsapp_conversations") .update({ state: "COACH_GOAL", temp_data: { ...conv!.temp_data, back_image: fileName } }) .eq("phone_number", senderNumber); await sendWhatsAppInteractiveButtons( remoteJid, "📸 Todas as fotos recebidas!\n\nAgora escolha o seu principal objetivo para o protocolo:", [ { id: "goal_hipertrofia", title: "💪 Hipertrofia" }, { id: "goal_emagrecer", title: "🔥 Emagrecimento" }, { id: "goal_definicao", title: "📐 Definição" } ] ); }, 0); return new Response("Coach Back image received", { status: 200 }); } // COACH_GOAL if (state === "COACH_GOAL") { // Offload long-running task to background setTimeout(async () => { let goal = "Hipertrofia"; if (interactiveId === "goal_emagrecer" || textMessage.includes("2") || /emagreci|secar/i.test(textMessage)) goal = "Emagrecimento"; else if (interactiveId === "goal_definicao" || textMessage.includes("3") || /defini/i.test(textMessage)) goal = "Definição"; else if (interactiveId === "goal_hipertrofia" || textMessage.includes("1") || /hiper/i.test(textMessage)) goal = "Hipertrofia"; else { await sendWhatsAppInteractiveButtons( remoteJid, "⚠️ Objetivo não reconhecido. Por favor, escolha uma das opções abaixo:", [ { id: "goal_hipertrofia", title: "💪 Hipertrofia" }, { id: "goal_emagrecer", title: "🔥 Emagrecimento" }, { id: "goal_definicao", title: "📐 Definição" } ] ); return; } await sendWhatsAppMessage( remoteJid, "🤖 Estou analisando seu físico e montando o plano com a IA...\nIsso pode levar cerca de 10-15 segundos." ); try { const { front_image, side_image, back_image } = conv!.temp_data; const images = [front_image, side_image, back_image]; const parts: any[] = [{ text: COACH_SYSTEM_PROMPT }, { text: `Objetivo: ${goal}` }]; for (const imgPath of images) { if (imgPath) { const { data: blob } = await supabase.storage.from("coach-uploads").download(imgPath); if (blob) { const buffer = await blob.arrayBuffer(); const base64 = btoa(String.fromCharCode(...new Uint8Array(buffer))); parts.push({ inlineData: { mimeType: "image/jpeg", data: base64 } }); } } } const genAI = new GoogleGenerativeAI(GEMINI_API_KEY); const model = genAI.getGenerativeModel({ model: "gemini-2.5-flash" }); const result = await model.generateContent({ contents: [{ role: "user", parts }], generationConfig: { temperature: 0.2, responseMimeType: "application/json" }, }); const responseText = result.response.text(); const plan = JSON.parse(responseText); // PREMIUM LAYOUT FOR COACH const lines: string[] = []; lines.push("📱 *SEU PROTOCOLO TITAN*"); lines.push(""); lines.push(`🧬 *BIÓTIPO*: ${plan.analysis?.somatotype} (${plan.analysis?.muscle_mass_level} massa muscular)`); lines.push(`🎯 *FOCO*: ${plan.workout?.focus}`); lines.push(""); lines.push("🏋️ *TREINO*"); lines.push(`▪ Divisão ${plan.workout?.split} (${plan.workout?.frequency_days}x/semana)`); lines.push(""); lines.push("🥗 *DIETA*"); lines.push(`▪ ${Math.round(plan.diet?.total_calories)} kcal`); lines.push(`▪ Proteína: ${plan.diet?.macros?.protein_g}g`); lines.push(`▪ Carboidrato: ${plan.diet?.macros?.carbs_g}g`); lines.push(`▪ Gordura: ${plan.diet?.macros?.fats_g}g`); lines.push(""); lines.push("💊 *SUPLEMENTOS*"); const sups = plan.diet?.supplements?.map((s: any) => s.name).join(", "); if (sups && sups.length > 0) { lines.push(`▪ ${sups}`); } else { lines.push(`▪ Não há recomendações`); } lines.push(""); lines.push(`💡 _DICA:_ ${plan.motivation_quote}`); lines.push(""); lines.push(`🚀 *Plano Completo Interativo:*`); lines.push(`Acesse https://foodsnap.com.br/meu-plano`); await sendWhatsAppMessage(remoteJid, lines.join('\n')); // ── Gerar PDF e enviar via WhatsApp ───────────────── try { const pdfFileName = `FoodSnap_Titan_${new Date().toISOString().split("T")[0]}`; const pdfHtml = buildCoachPdfHtml(plan); console.log("[WH] Generating PDF via n8n/Gotenberg..."); const pdfResponse = await fetch("https://n8n.seureview.com.br/webhook/pdf-coach", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ html: pdfHtml, file_name: pdfFileName }), }); if (pdfResponse.ok) { const pdfBlob = await pdfResponse.arrayBuffer(); const pdfBytes = new Uint8Array(pdfBlob); const storagePath = `${userId}/${pdfFileName}.pdf`; // Upload para Supabase Storage const { error: uploadErr } = await supabase.storage .from("coach-pdfs") .upload(storagePath, pdfBytes, { contentType: "application/pdf", upsert: true, }); if (uploadErr) { console.error("[WH] PDF upload error:", uploadErr); } else { // URL Assinada (funciona mesmo com bucket privado) const { data: urlData, error: signErr } = await supabase.storage .from("coach-pdfs") .createSignedUrl(storagePath, 60 * 60); // 1 hora de validade if (signErr || !urlData?.signedUrl) { console.error("[WH] Signed URL error:", signErr); } else { await sendWhatsAppDocument( remoteJid, urlData.signedUrl, `${pdfFileName}.pdf`, "📄 Seu Protocolo Titan completo em PDF!" ); } } } else { console.error("[WH] n8n PDF error:", pdfResponse.status, await pdfResponse.text()); } } catch (pdfErr) { console.error("[WH] PDF generation/send error (non-blocking):", pdfErr); // PDF is non-blocking — user already got the text summary } // ── Salvar análise coach (enriquecido p/ dashboard) ─ const { error: saveCoachErr } = await supabase.from("coach_analyses").insert({ user_id: userId, source: "whatsapp", ai_raw_response: responseText, ai_structured: plan, goal_suggestion: goal, biotype: plan.analysis?.somatotype || null, estimated_body_fat: parseFloat(String(plan.analysis?.body_fat_percentage || 0)) || 0, muscle_mass_level: plan.analysis?.muscle_mass_level || null, }); if (saveCoachErr) { console.error("[WH] Error saving coach analysis to DB:", saveCoachErr); } else { console.log("[WH] Coach analysis saved successfully for user:", userId); } // Reset state await supabase .from("whatsapp_conversations") .update({ state: "IDLE", temp_data: {} }) .eq("phone_number", senderNumber); } catch (err) { console.error("Coach Gen Error:", err); await sendWhatsAppMessage( remoteJid, "⚠️ Ocorreu um erro ao gerar seu plano. Tente novamente digitando 'Coach'." ); await supabase .from("whatsapp_conversations") .update({ state: "IDLE", temp_data: {} }) .eq("phone_number", senderNumber); } }, 0); return new Response("Coach Workflow Completed", { status: 200 }); } // ── 6. Food Scan Flow (IDLE) ──────────────────────────────── if (state === "IDLE") { console.log(`[WH] Entering Food Scan flow. isImage=${isImage}`); setTimeout(async () => { // 6a. Verificar plano e quota const { data: entitlement } = await supabase .from("user_entitlements") .select("is_active, valid_until, entitlement_code") .eq("user_id", userId) .match({ is_active: true }) .maybeSingle(); const isPaid = entitlement && ['pro', 'mensal', 'trimestral', 'anual', 'trial', 'paid'].includes(entitlement.entitlement_code) && (!entitlement.valid_until || new Date(entitlement.valid_until) > new Date()); if (!isPaid) { const { count: freeUsed } = await supabase .from("food_analyses") .select("*", { count: "exact", head: true }) .eq("user_id", userId) .eq("used_free_quota", true); if ((freeUsed || 0) >= FREE_FOOD_LIMIT) { await sendWhatsAppInteractiveMessage( remoteJid, `🚫 *Limite gratuito atingido*\nVocê já usou suas ${FREE_FOOD_LIMIT} análises grátis.\n\nAssine o plano PRO para escaneamento de alimentos e uso ilimitado do bot inteligente.`, "🚀 Assinar Plano PRO", "https://foodsnap.com.br" ); return; } } // 6b. Sem imagem → mensagem de boas-vindas if (!isImage) { // Verifica se é texto aleatório ignorável if (!interactiveId && /^\s*$/.test(textMessage)) { return; } await sendWhatsAppMessage( remoteJid, "👋 Olá! Envie uma *foto do seu prato* (bem nítida e de cima 📸) que eu te retorno *calorias e macronutrientes* em segundos.\n\nOu digite *Coach* para iniciar uma consultoria completa." ); return; } // 6c. Processar imagem await sendWhatsAppMessage(remoteJid, "📸 Recebi sua foto! Estou analisando o prato agora… ⏳"); const base64Image = await getWhatsAppMedia(messageId); if (!base64Image) { await sendWhatsAppMessage(remoteJid, "⚠️ Não consegui baixar a imagem. Tente enviar novamente."); return; } // 6d. Chamar Gemini const genAI = new GoogleGenerativeAI(GEMINI_API_KEY); const model = genAI.getGenerativeModel({ model: "gemini-2.5-flash" }); const geminiResult = await model.generateContent({ contents: [ { role: "user", parts: [ { text: SYSTEM_PROMPT }, { inlineData: { mimeType: "image/jpeg", data: base64Image } }, ], }, ], generationConfig: { temperature: 0.1, responseMimeType: "application/json" }, }); const rawResponseText = geminiResult.response.text(); // HOTFIX: Add telemetry 1 console.log("[TELEMETRY] Gemini Raw Response:", rawResponseText.substring(0, 100) + "..."); // ── Gamificação: Cálculo de Ofensiva (Streak) ── let newStreak = user?.current_streak || 0; let newLongest = user?.longest_streak || 0; let streakIncreased = false; try { if (user) { const now = new Date(); // BRT timezone (UTC-3) const brtz = new Date(now.getTime() - (3 * 60 * 60 * 1000)); const brTodayStr = brtz.toISOString().split("T")[0]; if (user.last_scan_date) { const yesterday = new Date(brtz); yesterday.setDate(yesterday.getDate() - 1); const yesterdayStr = yesterday.toISOString().split("T")[0]; if (user.last_scan_date === brTodayStr) { // Já escaneou hoje, mantém a ofensiva } else if (user.last_scan_date === yesterdayStr) { // Escaneou ontem, incrementa! newStreak += 1; streakIncreased = true; } else { // Quebrou a ofensiva. Reinicia no 1 newStreak = 1; streakIncreased = true; } } else { // Primeiro scan! newStreak = 1; streakIncreased = true; } if (newStreak > newLongest) { newLongest = newStreak; } // Salvar ofensive no DB se mudou a data (mesmo que tenha quebrado a ofensiva e reiniciado) ou submeteu streak if (streakIncreased || user.last_scan_date !== brTodayStr) { await supabase.from("profiles").update({ current_streak: newStreak, longest_streak: newLongest, last_scan_date: brTodayStr }).eq("id", userId); } } } catch (streakErr) { console.error("[WH] Error updating streak:", streakErr); } // 6e. Limpar e normalizar resultado let analysis: any; try { analysis = parseAndCleanGeminiResponse(rawResponseText); // HOTFIX: Add telemetry 2 console.log("[TELEMETRY] Parsed Analysis OK. Health Score:", analysis.health_score); } catch (parseErr) { console.error("[TELEMETRY] Parse error:", parseErr); await sendWhatsAppMessage( remoteJid, "⚠️ Houve um erro ao interpretar a análise. Tente enviar a foto novamente com boa iluminação." ); return; } // 6f. Formatar e tentar enviar a imagem renderizada let replyText = ""; try { replyText = formatWhatsAppResponse(analysis); console.log("[TELEMETRY] Format WhatsApp Response OK."); } catch (formatErr) { console.error("[TELEMETRY] Format text error:", formatErr); replyText = "Opa, ocorreu um problema com a validação gramatical do resuminho! ⚠️"; } let cardImageUrl = null; if (IMAGE_RENDERER_URL) { try { console.log("[WH] Generating Image Card via Puppeteer..."); const renderRes = await fetch(`${IMAGE_RENDERER_URL}/api/render`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ data: analysis }) }); if (renderRes.ok) { const imgBlob = await renderRes.arrayBuffer(); const cardPath = `${userId}/card_${Date.now()}.png`; const { error: uploadErr } = await supabase.storage .from("consultas") .upload(cardPath, new Uint8Array(imgBlob), { contentType: "image/png" }); if (!uploadErr) { const { data: { publicUrl } } = supabase.storage .from("consultas") .getPublicUrl(cardPath); cardImageUrl = publicUrl; } } } catch (e) { console.error("[WH] Failed to generate card image:", e); } } // Enviar Carta Gráfica (se houver) + Texto, senão envia só Texto puro. console.log("[TELEMETRY] Sending Final Output. Has Card Image?", !!cardImageUrl); try { if (cardImageUrl) { await sendWhatsAppImage(remoteJid, cardImageUrl); // Apenas a foto do card } else { await sendWhatsAppMessage(remoteJid, replyText); // Envia o dump de texto se falhar } } catch (sendErr) { console.error("[TELEMETRY] Error sending final whatsapp output:", sendErr); } // Enviar Notificação de Ofensiva (Gamificação) if (streakIncreased && newStreak >= 1) { let streakMsg = `🔥 *Ofensiva de ${newStreak} dia${newStreak > 1 ? 's' : ''}!* Continue assim para manter seu hábito saudável.`; if (newStreak > (user?.longest_streak || 0)) { streakMsg = `🏆 *Novo Recorde!* Ofensiva de ${newStreak} dias seguidos. Você está imbatível!`; } await sendWhatsAppMessage(remoteJid, streakMsg); } // 6f-2. Enviar botônicos (Follow-up de recomendações) await sendWhatsAppInteractiveButtons( remoteJid, "O que você gostaria de fazer com este prato?", [ { id: "action_coach", title: "🤖 Iniciar Treino" }, { id: "action_recommend", title: "🥣 O que comer mais?" }, ] ); // Atualizar last_analysis no temp_data para a re-pergunta "O que comer mais?" await supabase .from("whatsapp_conversations") .update({ state: "IDLE", temp_data: { last_analysis: rawResponseText } }) .eq("phone_number", senderNumber); // 6g. Mapear confidence para enum do banco const confidenceMap: Record = { alta: "high", media: "medium", média: "medium", baixa: "low", }; // 6h. Salvar no banco const { data: inserted } = await supabase .from("food_analyses") .insert({ user_id: userId, source: "whatsapp", image_url: null, // será atualizado após upload ai_raw_response: rawResponseText, ai_structured: analysis, total_calories: analysis.total?.calories || 0, total_protein: analysis.total?.protein || 0, total_carbs: analysis.total?.carbs || 0, total_fat: analysis.total?.fat || 0, total_fiber: analysis.total?.fiber || 0, total_sodium_mg: analysis.total?.sodium_mg || 0, nutrition_score: analysis.health_score || 0, confidence_level: confidenceMap[analysis.confidence] || "medium", used_free_quota: !isPaid, }) .select("id") .single(); // 6i. Upload imagem para Supabase Storage (bucket consultas) if (inserted?.id) { try { const imgPath = `${userId}/${inserted.id}.jpg`; const imgBytes = base64ToUint8Array(base64Image); await supabase.storage .from("consultas") .upload(imgPath, imgBytes, { contentType: "image/jpeg", upsert: true }); // Atualizar image_url no registro const { data: { publicUrl } } = supabase.storage .from("consultas") .getPublicUrl(imgPath); await supabase .from("food_analyses") .update({ image_url: publicUrl }) .eq("id", inserted.id); } catch (uploadErr) { console.error("Image upload error (non-fatal):", uploadErr); } } }, 0); return new Response("Food Scan Started", { status: 200 }); } } catch (criticalErr) { // Capture da função async flutuante console.error("[WH] Critical Background Error:", criticalErr); } }