c2c42a1620
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>
245 lines
9.5 KiB
TypeScript
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' }
|
|
})
|
|
}
|
|
})
|