/* |-------------------------------------------------------------------------- | Agência PSI — Edge Function: notification-webhook |-------------------------------------------------------------------------- | Recebe webhooks de status dos provedores (Evolution API, Meta, etc.) | e atualiza notification_logs + processa opt-out (SAIR). | | Runtime: Deno (Supabase Edge Functions) | Linguagem: JavaScript puro |-------------------------------------------------------------------------- */ import { createClient } from 'https://esm.sh/@supabase/supabase-js@2' const SUPABASE_URL = Deno.env.get('SUPABASE_URL') const SUPABASE_SERVICE_KEY = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') const EVOLUTION_API_KEY = Deno.env.get('EVOLUTION_API_KEY') || '' const META_VERIFY_TOKEN = Deno.env.get('META_VERIFY_TOKEN') || '' const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_KEY) Deno.serve(async (req) => { const url = new URL(req.url) const provider = url.searchParams.get('provider') || 'unknown' // Meta webhook verification (GET com challenge) if (req.method === 'GET' && provider === 'meta') { return handleMetaVerification(url) } if (req.method !== 'POST') { return jsonResponse({ error: 'Method not allowed' }, 405) } try { const body = await req.json() switch (provider) { case 'evolution': return await handleEvolutionWebhook(req, body) case 'meta': return await handleMetaWebhook(body) default: return jsonResponse({ error: `Provider "${provider}" não suportado` }, 400) } } catch (err) { console.error(`[webhook] Erro:`, err.message) return jsonResponse({ error: err.message }, 500) } }) // ── Evolution API Webhook ─────────────────────────────────── /** * Processa webhooks da Evolution API. * Eventos relevantes: * - messages.update: status de entrega (enviado, entregue, lido) * - messages.upsert: mensagem recebida (para detectar "SAIR") */ async function handleEvolutionWebhook (req, body) { // Validação básica da API key const apiKey = req.headers.get('apikey') || '' if (EVOLUTION_API_KEY && apiKey !== EVOLUTION_API_KEY) { return jsonResponse({ error: 'Invalid API key' }, 401) } const event = body.event const instance = body.instance console.log(`[evolution] Evento: ${event}, Instância: ${instance}`) // ── Status de mensagem enviada ──── if (event === 'messages.update') { const key = body.data?.key const messageId = key?.id const status = body.data?.update?.status if (!messageId) { return jsonResponse({ ok: true, skipped: 'no messageId' }) } // Mapeia status numérico da Evolution // 1 = pendente, 2 = enviado ao servidor, 3 = entregue, 4 = lido, 5 = erro const statusMap = { 1: null, // pendente — não atualiza 2: 'sent', // enviado ao servidor WhatsApp 3: 'delivered', // entregue ao dispositivo 4: 'read', // lido 5: 'failed', // erro } const mappedStatus = statusMap[status] if (!mappedStatus) { return jsonResponse({ ok: true, skipped: `status ${status} ignorado` }) } await updateLogStatus(messageId, mappedStatus) return jsonResponse({ ok: true, status: mappedStatus }) } // ── Mensagem recebida (para opt-out) ──── if (event === 'messages.upsert') { const message = body.data?.message const text = ( message?.conversation || message?.extendedTextMessage?.text || '' ).trim().toUpperCase() const remoteJid = body.data?.key?.remoteJid || '' const phone = remoteJid.replace('@s.whatsapp.net', '').replace('@c.us', '') // Detecta opt-out if (['SAIR', 'PARAR', 'STOP', 'CANCELAR MENSAGENS'].includes(text)) { console.log(`[evolution] Opt-out detectado: ${phone}`) await handleOptOut(phone, instance) return jsonResponse({ ok: true, action: 'opt_out' }) } // Detecta confirmação (futuro: atualizar status da sessão) if (['OK', '✅', 'CONFIRMAR', 'CONFIRMO', 'SIM'].includes(text)) { console.log(`[evolution] Confirmação detectada: ${phone}`) // TODO: buscar sessão pendente mais próxima e confirmar return jsonResponse({ ok: true, action: 'confirmation_detected' }) } return jsonResponse({ ok: true, action: 'message_ignored' }) } return jsonResponse({ ok: true, event_ignored: event }) } // ── Meta WhatsApp Webhook ─────────────────────────────────── /** * Verificação de webhook da Meta (challenge handshake). */ function handleMetaVerification (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) { return new Response(challenge, { status: 200 }) } return jsonResponse({ error: 'Verification failed' }, 403) } /** * Processa webhooks da Meta WhatsApp Business API. */ async function handleMetaWebhook (body) { const entries = body.entry || [] for (const entry of entries) { const changes = entry.changes || [] for (const change of changes) { const value = change.value || {} // ── Status de mensagem ──── if (value.statuses) { for (const st of value.statuses) { const messageId = st.id const status = st.status // sent, delivered, read, failed const errors = st.errors || [] if (messageId && status) { const mappedStatus = status === 'failed' ? 'failed' : status await updateLogStatus(messageId, mappedStatus, errors[0]?.message) } } } // ── Mensagens recebidas (opt-out) ──── if (value.messages) { for (const msg of value.messages) { const text = (msg.text?.body || '').trim().toUpperCase() const phone = msg.from || '' if (['SAIR', 'PARAR', 'STOP', 'CANCELAR MENSAGENS'].includes(text)) { console.log(`[meta] Opt-out detectado: ${phone}`) await handleOptOut(phone, null) } // Botão de resposta rápida (quick reply) if (msg.type === 'button' || msg.type === 'interactive') { const payload = msg.button?.payload || msg.interactive?.button_reply?.id || '' console.log(`[meta] Button reply: ${payload} de ${phone}`) // TODO: processar confirmação/cancelamento via botão } } } } } return jsonResponse({ ok: true }) } // ── Helpers compartilhados ────────────────────────────────── /** * Atualiza o status no notification_logs com base no provider_message_id. */ async function updateLogStatus (providerMessageId, status, failureReason) { const now = new Date().toISOString() const updateData = { provider_status: status } switch (status) { case 'sent': updateData.status = 'sent' break case 'delivered': updateData.status = 'delivered' updateData.delivered_at = now break case 'read': updateData.status = 'read' updateData.read_at = now break case 'failed': updateData.status = 'failed' updateData.failed_at = now updateData.failure_reason = failureReason || 'Falha reportada pelo provedor' break } const { error } = await supabase .from('notification_logs') .update(updateData) .eq('provider_message_id', providerMessageId) if (error) { console.warn(`[updateLogStatus] Erro ao atualizar ${providerMessageId}:`, error.message) } } /** * Processa opt-out: desativa WhatsApp para o paciente e cancela pendentes. * @param {string} phone - número de telefone (apenas dígitos) * @param {string|null} instanceName - nome da instância Evolution (para identificar owner) */ async function handleOptOut (phone, instanceName) { // Normaliza telefone const cleanPhone = String(phone).replace(/\D/g, '') if (!cleanPhone) return // Busca paciente(s) com esse telefone const { data: patients } = await supabase .from('patients') .select('id, owner_id, telefone') .or(`telefone.like.%${cleanPhone}%`) if (!patients || patients.length === 0) { console.warn(`[opt-out] Nenhum paciente encontrado para ${cleanPhone}`) return } for (const patient of patients) { // Atualiza preferência (o trigger cancela pendentes automaticamente) const { error } = await supabase .from('notification_preferences') .upsert({ owner_id: patient.owner_id, tenant_id: patient.owner_id, // será ajustado pelo context patient_id: patient.id, whatsapp_opt_in: false, lgpd_opt_out_date: new Date().toISOString(), lgpd_opt_out_reason: 'Paciente respondeu SAIR no WhatsApp', }, { onConflict: 'owner_id,patient_id', ignoreDuplicates: false, }) if (error) { console.error(`[opt-out] Erro ao salvar preferência para paciente ${patient.id}:`, error.message) } else { console.log(`[opt-out] WhatsApp desativado para paciente ${patient.id}`) } } } /** * Helper para respostas JSON padronizadas. */ function jsonResponse (data, status = 200) { return new Response(JSON.stringify(data), { status, headers: { 'Content-Type': 'application/json' }, }) }