9b21642e15
- _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>
244 lines
9.5 KiB
TypeScript
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' }
|
|
})
|
|
}
|
|
})
|