/* |-------------------------------------------------------------------------- | Agência PSI — Edge Function: send-whatsapp-message |-------------------------------------------------------------------------- | Envia mensagem WhatsApp via Evolution API da clínica. Registra a mensagem | imediatamente em conversation_messages (direction=outbound) para feedback | instantâneo no UI (via Realtime). Quando a Evolution ecoar a mensagem de | volta pelo webhook com fromMe=true, o evolution-whatsapp-inbound faz UPDATE | (não duplica) pelo provider_message_id. | | Body JSON: | { | "tenant_id": "", | "to_number": "5516912345678", // sem + nem @s.whatsapp.net | "body": "Texto da mensagem", | "patient_id": "" (opcional) // se já vinculado | } |-------------------------------------------------------------------------- */ import { createClient } from 'https://esm.sh/@supabase/supabase-js@2' const corsHeaders = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', 'Access-Control-Allow-Methods': 'POST, OPTIONS', } function json(body: unknown, status = 200) { return new Response(JSON.stringify(body), { status, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }) } function sanitizeBody(raw: string): string { return String(raw ?? '') .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '') .trim() .slice(0, 4000) } function normalizePhone(raw: string): string { const digits = String(raw ?? '').replace(/\D/g, '') if (digits.length >= 12 && digits.startsWith('55')) return digits if (digits.length === 11 || digits.length === 10) return '55' + digits return digits } // ═══════════════════════════════════════════════════════════════════ // Twilio send helper (com dedução de crédito) // ═══════════════════════════════════════════════════════════════════ type TwilioChannel = { twilio_subaccount_sid: string twilio_phone_number: string credentials: { subaccount_auth_token?: string } } async function sendViaTwilio( channel: TwilioChannel, toPhone: string, text: string ): Promise<{ ok: boolean; messageId?: string; status?: string; error?: string }> { const subSid = channel.twilio_subaccount_sid const authToken = channel.credentials?.subaccount_auth_token const fromNumber = channel.twilio_phone_number // E.164: +5511999990000 if (!subSid || !authToken || !fromNumber) { return { ok: false, error: 'Twilio credenciais incompletas' } } const endpoint = `https://api.twilio.com/2010-04-01/Accounts/${subSid}/Messages.json` const basicAuth = btoa(`${subSid}:${authToken}`) const toE164 = toPhone.startsWith('+') ? toPhone : `+${toPhone}` const params = new URLSearchParams() params.append('From', `whatsapp:${fromNumber}`) params.append('To', `whatsapp:${toE164}`) params.append('Body', text) try { const resp = await fetch(endpoint, { method: 'POST', headers: { 'Authorization': `Basic ${basicAuth}`, 'Content-Type': 'application/x-www-form-urlencoded' }, body: params.toString() }) const data = await resp.json().catch(() => null) as Record | null if (!resp.ok) { return { ok: false, error: `Twilio ${resp.status}: ${(data?.message as string) || JSON.stringify(data).slice(0, 200)}` } } return { ok: true, messageId: String(data?.sid || ''), status: String(data?.status || 'queued') } } catch (e) { return { ok: false, error: String(e) } } } // Edge function roda em container — localhost aponta pra ele mesmo, não pro host. // Substitui localhost/127.0.0.1 por host.docker.internal pra alcançar o host. function rewriteForContainer(apiUrl: string): string { try { const u = new URL(apiUrl) if (u.hostname === 'localhost' || u.hostname === '127.0.0.1') { u.hostname = 'host.docker.internal' return u.toString().replace(/\/+$/, '') } return apiUrl.replace(/\/+$/, '') } catch { return apiUrl } } Deno.serve(async (req: Request) => { if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders }) if (req.method !== 'POST') return json({ ok: false, error: 'method' }, 405) try { const body = await req.json().catch(() => null) if (!body || typeof body !== 'object') return json({ ok: false, error: 'body invalido' }, 400) const { tenant_id, to_number, body: text, patient_id } = body as Record const cleanText = sanitizeBody(text) const cleanPhone = normalizePhone(to_number) if (!tenant_id || !cleanPhone || !cleanText) { return json({ ok: false, error: 'faltam campos (tenant_id, to_number, body)' }, 400) } if (cleanText.length < 1) return json({ ok: false, error: 'mensagem vazia' }, 400) // Autenticação const auth = req.headers.get('Authorization') ?? '' const supaAuthed = createClient( Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_ANON_KEY')!, { global: { headers: { Authorization: auth } } } ) const { data: authData, error: authErr } = await supaAuthed.auth.getUser() if (authErr || !authData?.user) return json({ ok: false, error: 'auth' }, 401) const supaSvc = createClient( Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')! ) const userId = authData.user.id // Valida membership const { data: isAdmin } = await supaSvc.rpc('is_saas_admin') if (!isAdmin) { const { data: membership } = await supaSvc .from('tenant_members') .select('id') .eq('tenant_id', tenant_id) .eq('user_id', userId) .eq('status', 'active') .maybeSingle() if (!membership) return json({ ok: false, error: 'forbidden' }, 403) } // Busca canal (Evolution ou Twilio) const { data: channel, error: chErr } = await supaSvc .from('notification_channels') .select('credentials, provider, twilio_subaccount_sid, twilio_phone_number, is_active') .eq('tenant_id', tenant_id) .eq('channel', 'whatsapp') .is('deleted_at', null) .maybeSingle() if (chErr || !channel) { return json({ ok: false, error: 'Canal WhatsApp nao configurado pra este tenant' }, 400) } if (!channel.is_active) { return json({ ok: false, error: 'Canal WhatsApp inativo. Verifique /configuracoes/whatsapp.' }, 400) } const provider = channel.provider // 'evolution' ou 'twilio' // Resolve patient_id se não veio (match por telefone) let resolvedPatientId = patient_id ?? null if (!resolvedPatientId) { const { data: matchData } = await supaSvc.rpc('match_patient_by_phone', { p_tenant_id: tenant_id, p_phone: cleanPhone }) resolvedPatientId = (matchData as string | null) ?? null } // ───────────────────────────────────────────────────── // TWILIO: deduz crédito ANTES, envia, refunda se falhar // ───────────────────────────────────────────────────── if (provider === 'twilio') { // Deduz 1 crédito de forma atômica (RPC lança 'insufficient_credits' se zerado) const { error: dedErr } = await supaSvc.rpc('deduct_whatsapp_credits', { p_tenant_id: tenant_id, p_amount: 1, p_conversation_message_id: null, p_note: 'Envio manual WhatsApp' }) if (dedErr) { const insufficient = String(dedErr.message || '').includes('insufficient_credits') return json({ ok: false, error: insufficient ? 'insufficient_credits' : dedErr.message, message: insufficient ? 'Saldo insuficiente. Compre créditos em /configuracoes/creditos-whatsapp.' : 'Erro ao deduzir créditos' }, insufficient ? 402 : 500) } // Envia via Twilio const sendRes = await sendViaTwilio(channel as unknown as TwilioChannel, cleanPhone, cleanText) if (!sendRes.ok) { // Refunda o crédito deduzido await supaSvc.rpc('add_whatsapp_credits', { p_tenant_id: tenant_id, p_amount: 1, p_kind: 'refund', p_purchase_id: null, p_admin_id: null, p_note: `Refund envio falhou: ${sendRes.error?.slice(0, 200)}` }) return json({ ok: false, error: `twilio_send_failed: ${sendRes.error}` }, 502) } // Registra mensagem + vincula a dedução (se possível — já foi feita sem msg_id) const { data: inserted, error: insErr } = await supaSvc.from('conversation_messages').insert({ tenant_id, patient_id: resolvedPatientId, channel: 'whatsapp', direction: 'outbound', from_number: null, to_number: cleanPhone, body: cleanText, provider: 'twilio', provider_message_id: sendRes.messageId ?? null, provider_raw: { status: sendRes.status }, kanban_status: 'awaiting_patient', received_at: null, responded_at: new Date().toISOString(), delivery_status: sendRes.status === 'delivered' ? 'delivered' : sendRes.status === 'sent' ? 'sent' : 'queued' }).select('id').single() if (insErr) { console.error('[send-whatsapp-message] insert error (twilio):', insErr) return json({ ok: false, error: 'Enviado + creditado mas falhou ao registrar: ' + insErr.message }, 500) } return json({ ok: true, provider: 'twilio', message_id: inserted.id, provider_message_id: sendRes.messageId }) } // ───────────────────────────────────────────────────── // EVOLUTION: sem dedução (tier gratuito) // ───────────────────────────────────────────────────── const creds = (channel.credentials ?? {}) as Record const apiUrl = creds.api_url const apiKey = creds.api_key const instance = creds.instance_name if (!apiUrl || !apiKey || !instance) { return json({ ok: false, error: 'Credenciais Evolution incompletas' }, 400) } const evoEndpoint = `${rewriteForContainer(apiUrl)}/message/sendText/${encodeURIComponent(instance)}` const evoBody = { number: cleanPhone, text: cleanText } const evoResp = await fetch(evoEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', 'apikey': apiKey }, body: JSON.stringify(evoBody) }) const evoText = await evoResp.text() let evoJson: Record | null = null try { evoJson = JSON.parse(evoText) } catch { /* fica null */ } if (!evoResp.ok) { return json({ ok: false, error: `Evolution retornou ${evoResp.status}`, evolution_response: evoJson ?? evoText }, 502) } const providerMessageId = (evoJson as { key?: { id?: string } } | null)?.key?.id ?? null const { data: inserted, error: insErr } = await supaSvc.from('conversation_messages').insert({ tenant_id, patient_id: resolvedPatientId, channel: 'whatsapp', direction: 'outbound', from_number: null, to_number: cleanPhone, body: cleanText, provider: 'evolution', provider_message_id: providerMessageId, provider_raw: evoJson ?? null, kanban_status: 'awaiting_patient', received_at: null, responded_at: new Date().toISOString(), delivery_status: 'sent' }).select('id').single() if (insErr) { console.error('[send-whatsapp-message] insert error (evolution):', insErr) return json({ ok: false, error: 'Enviado mas falhou ao registrar: ' + insErr.message }, 500) } return json({ ok: true, provider: 'evolution', message_id: inserted.id, provider_message_id: providerMessageId, evolution_response: evoJson }) } catch (err) { return json({ ok: false, error: String(err) }, 500) } })