2026-02-20 19:03:15 +00:00
import { serve } from "https://deno.land/std@0.168.0/http/server.ts" ;
2026-02-26 00:17:54 +00:00
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" ;
2026-02-20 19:03:15 +00:00
2026-02-26 00:17:54 +00:00
// ─── 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" ) ;
2026-02-20 19:03:15 +00:00
2026-02-26 00:17:54 +00:00
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 ) ;
2026-02-20 19:03:15 +00:00
2026-02-26 00:17:54 +00:00
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 ) ;
2026-02-20 19:03:15 +00:00
}
2026-02-26 00:17:54 +00:00
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 ;
2026-02-20 19:03:15 +00:00
try {
2026-02-26 00:17:54 +00:00
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 ) ;
}
}
2026-02-20 19:03:15 +00:00
2026-02-26 00:17:54 +00:00
/** 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 } ` ) ;
}
}
2026-02-20 19:03:15 +00:00
2026-02-26 00:17:54 +00:00
/** 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 ) ;
}
}
2026-02-20 19:03:15 +00:00
2026-02-26 00:17:54 +00:00
/** 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 ) ;
}
}
2026-02-20 19:03:15 +00:00
2026-02-26 00:17:54 +00:00
/** 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 } ` ) ;
2026-02-20 19:03:15 +00:00
} catch ( err ) {
2026-02-26 00:17:54 +00:00
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 < string | null > {
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 < string , any > ( ) ;
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 } ` ) ;
2026-02-20 19:03:15 +00:00
}
2026-02-26 00:17:54 +00:00
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 } ) ;
}
2026-02-20 19:03:15 +00:00
} ) ;
2026-02-26 00:17:54 +00:00
// ─── 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 < string , string > = {
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 ) ;
}
}