/* |-------------------------------------------------------------------------- | Agência PSI — Edge Function: twilio-whatsapp-inbound |-------------------------------------------------------------------------- | Recebe mensagens WhatsApp RECEBIDAS pelos tenants via Twilio. | Diferente do twilio-whatsapp-webhook (que trata apenas status de mensagens | enviadas), este recebe o conteudo de respostas dos pacientes. | | URL configurada no numero da subconta de cada tenant, campo "WHEN A | MESSAGE COMES IN": | https://.supabase.co/functions/v1/twilio-whatsapp-inbound?tenant_id= | | Hooks aplicados (provider-agnostico via _shared/whatsapp-hooks): | 1) Opt-IN detection (keywords "voltar", "retornar", ...) | 2) Opt-OUT detection (keywords do tenant + system) com ack automatico | 3) Auto-reply fora do horario (se nao houve opt-out) com credit deduction | | Custos: cada envio (ack de opt-out + auto-reply) DEDUZ 1 credito do tenant. | Se o saldo estiver zerado, o hook falha silenciosamente (nao envia). |-------------------------------------------------------------------------- */ import { createClient } from 'https://esm.sh/@supabase/supabase-js@2' import { buildThreadKey, detectOptoutKeyword, maybeOptIn, maybeSendAutoReply, makeTwilioCreditedSendFn, registerOptout, type TwilioChannel } from '../_shared/whatsapp-hooks.ts' const corsHeaders = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', 'Access-Control-Allow-Methods': 'POST, GET, OPTIONS', } const EMPTY_TWIML = '' function stripWhatsappPrefix(raw: string | null | undefined): string | null { if (!raw) return null return raw.replace(/^whatsapp:/i, '').trim() } Deno.serve(async (req: Request) => { if (req.method === 'OPTIONS') { return new Response('ok', { headers: corsHeaders }) } if (req.method !== 'POST') { return new Response(EMPTY_TWIML, { status: 200, headers: { ...corsHeaders, 'Content-Type': 'text/xml' } }) } try { const url = new URL(req.url) const tenantId = url.searchParams.get('tenant_id') if (!tenantId) { console.error('[twilio-whatsapp-inbound] tenant_id ausente na URL') return new Response(EMPTY_TWIML, { status: 200, headers: { ...corsHeaders, 'Content-Type': 'text/xml' } }) } const supabase = createClient( Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')! ) const formData = await req.formData() const from = stripWhatsappPrefix(formData.get('From') as string) const to = stripWhatsappPrefix(formData.get('To') as string) const body = (formData.get('Body') as string) ?? null const messageSid = formData.get('MessageSid') as string const numMedia = parseInt((formData.get('NumMedia') as string) ?? '0', 10) const mediaUrl = numMedia > 0 ? (formData.get('MediaUrl0') as string) : null const mediaMime = numMedia > 0 ? (formData.get('MediaContentType0') as string) : null if (!from || !messageSid) { console.warn('[twilio-whatsapp-inbound] payload incompleto') return new Response(EMPTY_TWIML, { status: 200, headers: { ...corsHeaders, 'Content-Type': 'text/xml' } }) } const cleanBody = body ? String(body).replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '').slice(0, 4000) : null const { data: matchData } = await supabase.rpc('match_patient_by_phone', { p_tenant_id: tenantId, p_phone: from }) const patientId = matchData as string | null const raw: Record = { MessageSid: messageSid, AccountSid: formData.get('AccountSid') ?? null, From: from, To: to, NumMedia: numMedia, ProfileName: formData.get('ProfileName') ?? null, WaId: formData.get('WaId') ?? null, } const { error: insErr } = await supabase.from('conversation_messages').insert({ tenant_id: tenantId, patient_id: patientId, channel: 'whatsapp', direction: 'inbound', from_number: from, to_number: to, body: cleanBody, media_url: mediaUrl, media_mime: mediaMime, provider: 'twilio', provider_message_id: messageSid, provider_raw: raw, kanban_status: 'awaiting_us', received_at: new Date().toISOString() }) if (insErr) { console.error('[twilio-whatsapp-inbound] insert error:', insErr) return new Response(EMPTY_TWIML, { status: 200, headers: { ...corsHeaders, 'Content-Type': 'text/xml' } }) } // ───────────────────────────────────────────────────── // Hooks: opt-in, opt-out, auto-reply // ───────────────────────────────────────────────────── let optoutAction: 'in' | 'out' | null = null let autoReplyResult: { sent: boolean; reason?: string } | null = null try { // Busca canal Twilio (uma vez) — reutilizado por registerOptout/autoReply const { data: channel } = await supabase .from('notification_channels') .select('credentials, provider, twilio_subaccount_sid, twilio_phone_number, is_active') .eq('tenant_id', tenantId) .eq('channel', 'whatsapp') .is('deleted_at', null) .maybeSingle() if (channel && channel.is_active && channel.provider === 'twilio') { // 1) Opt-IN (voltar) tem prioridade if (await maybeOptIn(supabase, tenantId, from, cleanBody)) { optoutAction = 'in' } // 2) Opt-OUT por keyword — envia ack via Twilio (deduz 1 credito) if (!optoutAction) { const keyword = await detectOptoutKeyword(supabase, tenantId, cleanBody) if (keyword) { const optoutSendFn = makeTwilioCreditedSendFn( supabase, tenantId, channel as unknown as TwilioChannel, 'Opt-out ack WhatsApp' ) await registerOptout( supabase, tenantId, from, patientId, cleanBody, keyword, 'twilio', optoutSendFn ) optoutAction = 'out' } } // 3) Auto-reply fora do horario (so se nao foi opt-out) if (optoutAction !== 'out') { const threadKey = buildThreadKey(patientId, from) const arSendFn = makeTwilioCreditedSendFn( supabase, tenantId, channel as unknown as TwilioChannel, 'Auto-reply WhatsApp' ) autoReplyResult = await maybeSendAutoReply( supabase, tenantId, threadKey, from, 'twilio', arSendFn ) } } } catch (err) { console.error('[twilio-whatsapp-inbound] hooks error:', err) } // Twilio espera TwiML vazio (nao reenvia); logs no console console.log('[twilio-whatsapp-inbound] processed', { tenantId, from, optoutAction, autoReplyResult }) return new Response(EMPTY_TWIML, { status: 200, headers: { ...corsHeaders, 'Content-Type': 'text/xml' } }) } catch (err) { console.error('[twilio-whatsapp-inbound] fatal:', err) return new Response(EMPTY_TWIML, { status: 200, headers: { ...corsHeaders, 'Content-Type': 'text/xml' } }) } })