Files
agenciapsilmno/supabase/functions/twilio-whatsapp-inbound/index.ts
T
Leonardo 9b21642e15 F4 schema-per-tenant: edge functions roteiam pro schema do tenant
- _shared/tenant.ts: helper (adminClient, tenantDbForId, schemaForTenant,
  listTenantSchemas, resolveTenantByChannel, tenantSchemaName)
- _shared/whatsapp-hooks.ts: hooks de tabela tenant recebem tdb; RPCs de
  credito (deduct/add_whatsapp_credits) e tenant_members seguem em supa+p_tenant_id
- inbound (twilio/evolution): tenant_id da URL -> tdb pra conversation_messages
  e notification_channels
- crons de fila (process-notification/email/sms/whatsapp-queue): varrem
  listTenantSchemas e drenam a fila de cada schema (Q3: filas sao per-tenant);
  modo single-tenant se body.tenant_id vier
- crons reminders/checks (send-session-reminders, conversation-sla-check,
  whatsapp-heartbeat-check, convert-abandoned-intakes, sync-email-templates):
  loop por tenant
- routing por tenant_id (send-whatsapp-message, send-session-reminder-manual,
  twilio-provision, de/reactivate-channel, twilio-webhook): tenantDbForId;
  channel-actions sem tenant_id varrem schemas por channel_id
- asaas-*: tenant_id do body -> tdb; asaas-webhook fica global (whatsapp_credit_purchases)
- notification-webhook (Meta): resolve tenant via channel_routing por phone_number_id,
  fan-out por message_id quando nao resolve
- caller send-session-reminder-manual passa tenant_id (evento vive no schema)

Pendente: save-intake-progress e fluxos anon por token (decisao de roteamento)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 08:44:09 -03:00

244 lines
9.5 KiB
TypeScript

/*
|--------------------------------------------------------------------------
| 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://<project>.supabase.co/functions/v1/twilio-whatsapp-inbound?tenant_id=<uuid>
|
| 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,
maybeProcessBot,
maybeSendAutoReply,
makeTwilioCreditedSendFn,
registerOptout,
type TwilioChannel
} from '../_shared/whatsapp-hooks.ts'
import { tenantDbForId } from '../_shared/tenant.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 = '<?xml version="1.0" encoding="UTF-8"?><Response></Response>'
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 tdb = await tenantDbForId(supabase, tenantId)
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<string, unknown> = {
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 tdb.from('conversation_messages').insert({
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 tdb
.from('notification_channels')
.select('credentials, provider, twilio_subaccount_sid, twilio_phone_number, is_active')
.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(tdb, from, cleanBody)) {
optoutAction = 'in'
}
// 2) Opt-OUT por keyword — envia ack via Twilio (deduz 1 credito)
if (!optoutAction) {
const keyword = await detectOptoutKeyword(tdb, cleanBody)
if (keyword) {
const optoutSendFn = makeTwilioCreditedSendFn(
supabase,
tenantId,
channel as unknown as TwilioChannel,
'Opt-out ack WhatsApp'
)
await registerOptout(
tdb,
from,
patientId,
cleanBody,
keyword,
'twilio',
optoutSendFn
)
optoutAction = 'out'
}
}
// 3) Bot de triagem (tem precedência sobre auto-reply)
let botProcessed = false
if (optoutAction !== 'out') {
const threadKey = buildThreadKey(patientId, from)
const botSendFn = makeTwilioCreditedSendFn(
supabase,
tenantId,
channel as unknown as TwilioChannel,
'Bot de triagem WhatsApp'
)
const botRes = await maybeProcessBot(
tdb,
supabase,
tenantId,
threadKey,
patientId,
from,
body,
botSendFn
)
botProcessed = !!botRes.processed
}
// 4) Auto-reply fora do horario (só se não foi opt-out nem bot)
if (optoutAction !== 'out' && !botProcessed) {
const threadKey = buildThreadKey(patientId, from)
const arSendFn = makeTwilioCreditedSendFn(
supabase,
tenantId,
channel as unknown as TwilioChannel,
'Auto-reply WhatsApp'
)
autoReplyResult = await maybeSendAutoReply(
tdb,
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' }
})
}
})