Files
Leonardo c2c42a1620 3.7 Bot auto-triagem WhatsApp: config por tenant + hook nos inbound
Bot que coleta nome, motivo de busca e preferências ANTES do paciente
entrar no fluxo humano. Terapeuta abre a conversa e já encontra
resumo em conversation_notes.

Banco (migration 20260423000007):
- conversation_bots: config 1 por tenant. enabled, greeting/closing
  messages, steps (JSONB array de {prompt, variable, type}), trigger_mode
  (new_contact | all_unassigned | keyword), trigger_keywords[],
  idle_timeout_minutes, respect_optout.
  Defaults vêm com 4 perguntas úteis: nome, motivo, modalidade,
  horário preferido.
- conversation_bot_sessions: estado por thread. current_step,
  collected_data JSONB, status (active | completed | abandoned_idle |
  abandoned_manual | opted_out). UNIQUE parcial garante 1 ativa por
  (tenant, thread).
- RLS: leitura tenant/saas_admin, escrita admins (config) + service_role
  (sessions, só edge altera).

Shared (_shared/whatsapp-hooks.ts):
- maybeProcessBot: carrega config, busca sessão ativa, avança step
  com resposta, envia próxima pergunta via SendFn. Ao esgotar steps,
  envia closing + cria conversation_notes com resumo das variáveis
  coletadas. Se humano assume (conversation_assignments preenchido),
  sessão marca 'abandoned_manual' e bot sai.
- Trigger modes:
  - 'new_contact' (default): só inicia pra thread sem histórico bot
    E sem paciente vinculado (lead real).
  - 'all_unassigned': qualquer thread sem assignee.
  - 'keyword': matched contra lista; normalizeForMatch já existe.

Integração nos inbound (ambos providers):
- evolution-whatsapp-inbound: chama maybeProcessBot após opt-in/opt-out,
  ANTES do auto-reply. Se bot processou, skip auto-reply (senão duas
  respostas sobrepostas).
- twilio-whatsapp-inbound: idem, usando makeTwilioCreditedSendFn pra
  cobrar crédito de cada mensagem enviada pelo bot.

UI (/configuracoes/conversas-bots):
- Toggle enabled + Select trigger_mode + (se keyword) chips de keywords.
- Textareas greeting/closing.
- Editor de steps: reordenar (up/down), remover, add, editor com prompt
  e variable (regex /^[a-z_][a-z0-9_]*$/).
- Botão "Padrão" restaura mensagens/steps default.
- InputNumber idle_timeout + toggle respect_optout.
- Card inferior: últimas 30 sessões (7 dias) com status, contato,
  nome coletado (primeiro campo), progresso (step X/N), início.
- Entrada na landing de configurações + rota /configuracoes/conversas-bots.

Caveat conhecido: a resolução de conversation_notes.created_by usa
o primeiro admin ativo do tenant (pickAnyAdmin). Pra uma v2 seria
ideal ter um user "bot" sintético dedicado.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 13:54:53 -03:00

245 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'
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 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 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) 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(
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(
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' }
})
}
})