/** * CDP Edge — WhatsApp Cloud API v25.0 + HMAC Verification * sendWhatsApp, processWhatsAppWebhook, verifyHmac, sendCallMeBot */ import { sha256, normalizePhone } from '../utils.js'; import { saveLead, logApiFailure } from '../db.js'; import { Env, TrackPayload } from '../../types.js'; import { ExecutionContext } from '@cloudflare/workers-types'; import { pushLeadToZapmanCrm } from './crm.js'; // ── Tipos ─────────────────────────────────────────────────────────────────────── interface WhatsAppOptions { to?: string; template?: { name: string; language?: string; components?: any[]; }; mediaType?: 'image' | 'document' | 'video' | 'audio'; mediaUrl?: string; caption?: string; filename?: string; interactive?: 'buttons' | 'list'; bodyText?: string; buttons?: Array<{ id: string; title: string }>; listButton?: string; rows?: Array<{ id: string; title: string; description?: string }>; } interface WhatsAppMessage { from: string; id: string; type: string; text?: { body: string }; referral?: { ctwa_clid?: string; source_id?: string; source_url?: string; headline?: string; }; } // ── Resolvedores de secrets (canônico + legado) ──────────────────────────────── // Meta Cloud API v25.0 usa PHONE_NUMBER_ID e ACCESS_TOKEN como termos oficiais. // Suportamos ambos os nomes para compatibilidade com secrets já configurados. function resolvePhoneNumberId(env: Env): string | undefined { return env.WHATSAPP_PHONE_NUMBER_ID || env.WA_PHONE_ID; } function resolveAccessToken(env: Env): string | undefined { return env.WHATSAPP_ACCESS_TOKEN || env.WA_ACCESS_TOKEN; } // ── sendWhatsApp — envia mensagem via Meta Cloud API ────────────────────────── export async function sendWhatsApp(env: Env, tipo: string, payload: TrackPayload, options: WhatsAppOptions = {}): Promise { if (!resolvePhoneNumberId(env) || !resolveAccessToken(env) || !env.WA_NOTIFY_NUMBER) { return { skipped: 'WhatsApp não configurado' }; } const to = options.to || env.WA_NOTIFY_NUMBER; if (options.template) { const { name, language = 'pt_BR', components = [] } = options.template; const body = { messaging_product: 'whatsapp', to, type: 'template', template: { name, language: { code: language }, components } }; return _sendWARequest(env, body); } if (options.mediaType && options.mediaUrl) { const mediaPayload: Record = { link: options.mediaUrl }; if (options.caption) mediaPayload.caption = options.caption; if (options.filename) mediaPayload.filename = options.filename; const body = { messaging_product: 'whatsapp', to, type: options.mediaType, [options.mediaType]: mediaPayload }; return _sendWARequest(env, body); } if (options.interactive === 'buttons' && options.buttons && options.buttons.length > 0) { const body = { messaging_product: 'whatsapp', to, type: 'interactive', interactive: { type: 'button', body: { text: options.bodyText || '' }, action: { buttons: options.buttons.slice(0, 3).map(b => ({ type: 'reply', reply: { id: b.id, title: b.title } })), }, }, }; return _sendWARequest(env, body); } if (options.interactive === 'list' && options.rows && options.rows.length > 0) { const body = { messaging_product: 'whatsapp', to, type: 'interactive', interactive: { type: 'list', body: { text: options.bodyText || '' }, action: { button: options.listButton || 'Ver opções', sections: [{ rows: options.rows.slice(0, 10) }] }, }, }; return _sendWARequest(env, body); } // Text fallback (dentro da janela de 24h) const nome = [payload.firstName, payload.lastName].filter(Boolean).join(' ') || 'sem nome'; const valor = payload.value ? `R$ ${parseFloat(String(payload.value)).toFixed(2)}` : '—'; const utm = payload.utmSource || 'direto'; const produto = payload.contentName || ''; let texto = ''; if (tipo === 'Purchase') { texto = `🛒 *Nova Venda!*\n\n` + `👤 ${nome}\n📧 ${payload.email || '—'}\n📱 ${payload.phone || '—'}\n` + `💰 ${valor}\n${produto ? `📦 ${produto}\n` : ''}` + `🔗 UTM: ${utm}\n🕐 ${new Date().toLocaleString('pt-BR', { timeZone: 'America/Sao_Paulo' })}`; } else if (tipo === 'Lead') { texto = `📋 *Novo Lead!*\n\n` + `📧 ${payload.email || '—'}\n🔗 UTM: ${utm}\n` + `🌐 ${payload.pageUrl || '—'}\n🕐 ${new Date().toLocaleString('pt-BR', { timeZone: 'America/Sao_Paulo' })}`; } else { return { skipped: `tipo ${tipo} não suportado sem template` }; } return _sendWARequest(env, { messaging_product: 'whatsapp', to, type: 'text', text: { body: texto } }); } // ── _sendWARequest — executor interno ───────────────────────────────────────── async function _sendWARequest(env: Env, body: Record): Promise { try { const phoneNumberId = resolvePhoneNumberId(env); const accessToken = resolveAccessToken(env); const res = await fetch(`https://graph.facebook.com/v25.0/${phoneNumberId}/messages`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${accessToken}` }, body: JSON.stringify(body), }); const data = await res.json(); if (!res.ok) console.error('WhatsApp Meta API error:', res.status, (data as any).error?.message || 'unknown'); return { ok: res.ok, status: res.status, data }; } catch (err: any) { console.error('WhatsApp Meta API failed:', err?.message || String(err)); return { ok: false, error: err?.message || String(err) }; } } // ── sendCallMeBot — alertas de sistema via WhatsApp ─────────────────────────── export async function sendCallMeBot(env: Env, mensagem: string): Promise { if (!env.CALLMEBOT_PHONE || !env.CALLMEBOT_APIKEY) { return { skipped: 'CallMeBot não configurado' }; } try { const url = `https://api.callmebot.com/whatsapp.php?phone=${encodeURIComponent(env.CALLMEBOT_PHONE)}&text=${encodeURIComponent(mensagem)}&apikey=${env.CALLMEBOT_APIKEY}`; const res = await fetch(url); return { ok: res.ok, status: res.status }; } catch (err: any) { console.error('CallMeBot failed:', err?.message || String(err)); return { ok: false, error: err?.message || String(err) }; } } // ── processWhatsAppWebhook — CTWA (Click to WhatsApp) ──────────────────────── export async function processWhatsAppWebhook(env: Env, body: any, request: Request, ctx: ExecutionContext): Promise { const entry: any = body?.entry?.[0]; const change = entry?.changes?.find((c: any) => c.field === 'messages'); if (!change) return { skipped: 'no messages field' }; const messages = change.value?.messages; if (!messages || messages.length === 0) return { skipped: 'no messages' }; const results: any[] = []; for (const message of messages) { const phone = message.from; const wamid = message.id; const referral = message.referral || {}; const ctwaClid = referral.ctwa_clid || null; const adId = referral.source_id || null; const sourceUrl = referral.source_url || null; const headline = referral.headline || null; const messageBody = message.text?.body || message.type || ''; if (!phone) { results.push({ skipped: 'no phone' }); continue; } const phoneNorm = normalizePhone(phone) || phone; const phoneHash = await sha256(phoneNorm); // Deduplicação por wamid if (env.DB && wamid) { try { const existing = await env.DB.prepare('SELECT id FROM whatsapp_contacts WHERE wamid = ?').bind(wamid).first(); if (existing) { results.push({ skipped: 'duplicate wamid', wamid }); continue; } } catch { /* não bloquear se D1 falhar */ } } const eventId = `ctwa_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; if (env.DB) { ctx.waitUntil( env.DB.prepare( `INSERT OR IGNORE INTO whatsapp_contacts (phone_hash, phone_raw, wamid, ctwa_clid, ad_id, source_url, headline, capi_sent, capi_event_id, message_body) VALUES (?,?,?,?,?,?,?,0,?,?)` ).bind(phoneHash, phoneNorm, wamid || null, ctwaClid, adId, sourceUrl, headline, eventId, messageBody || null).run() ); } const capiEvent: Record = { event_name: 'Contact', event_time: Math.floor(Date.now() / 1000), event_id: eventId, action_source: 'chat', user_data: { ph: phoneHash, ...(ctwaClid && { ctwa_clid: ctwaClid }), client_ip_address: request.headers.get('CF-Connecting-IP') || '', client_user_agent: request.headers.get('User-Agent') || '', }, ...(sourceUrl && { event_source_url: sourceUrl }), }; ctx.waitUntil( (async () => { try { const requestBody: Record = { data: [capiEvent], access_token: env.META_ACCESS_TOKEN }; if (env.META_TEST_CODE) requestBody.test_event_code = env.META_TEST_CODE; const res = await fetch( `https://graph.facebook.com/v25.0/${env.META_PIXEL_ID}/events`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(requestBody) } ); const data = await res.json(); if (res.ok && env.DB && wamid) { await env.DB.prepare('UPDATE whatsapp_contacts SET capi_sent = 1 WHERE wamid = ?').bind(wamid).run(); } else if (!res.ok) { console.error('[CTWA] Meta CAPI error:', res.status, (data as any).error?.message || 'unknown'); if (env.DB) { await logApiFailure(env.DB, 'meta', 'Contact', (data as any).error?.code || res.status, (data as any).error?.message || 'CTWA CAPI error', eventId, JSON.stringify(requestBody)); } } } catch (err: any) { console.error('[CTWA] Meta CAPI fetch failed:', err?.message || String(err)); } })() ); ctx.waitUntil( saveLead(env, 'Contact', { phone: phoneNorm, eventId, pageUrl: sourceUrl, utmSource: 'whatsapp_ctwa', utmMedium: 'paid_social', }, request, 'whatsapp') ); // ── ZapMan CRM — cria lead direto no Kanban ────────────────────────────── ctx.waitUntil( pushLeadToZapmanCrm(env, { phone: phoneNorm, name: undefined, origem: 'whatsapp' }) ); results.push({ ok: true, phone: phoneNorm.slice(0, 4) + '****', ctwa_clid: ctwaClid ? 'present' : 'absent', event_id: eventId }); } return { processed: results.length, results }; } // ── verifyHmac — validação constant-time de assinatura HMAC-SHA256 ───────────── export async function verifyHmac(secret: string, rawBody: string, receivedSignature: string): Promise { if (!secret || !receivedSignature) return false; try { const normalizedSignature = receivedSignature.startsWith('sha256=') ? receivedSignature.slice('sha256='.length) : receivedSignature; const key = await crypto.subtle.importKey( 'raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'] ); const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(rawBody)); const computed = Array.from(new Uint8Array(sig)).map(b => b.toString(16).padStart(2, '0')).join(''); if (computed.length !== normalizedSignature.length) return false; let diff = 0; for (let i = 0; i < computed.length; i++) { diff |= computed.charCodeAt(i) ^ normalizedSignature.toLowerCase().charCodeAt(i); } return diff === 0; } catch { return false; } }