From c2c42a162011a3b939c963a627cbfbbb9f1aabb8 Mon Sep 17 00:00:00 2001 From: Leonardo Date: Thu, 23 Apr 2026 13:54:53 -0300 Subject: [PATCH] 3.7 Bot auto-triagem WhatsApp: config por tenant + hook nos inbound MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../20260423000007_conversation_bot.sql | 182 ++++++++ src/layout/ConfiguracoesPage.vue | 7 + .../ConfiguracoesConversasBotsPage.vue | 399 ++++++++++++++++++ src/router/routes.configs.js | 5 + supabase/functions/_shared/whatsapp-hooks.ts | 220 ++++++++++ .../evolution-whatsapp-inbound/index.ts | 19 +- .../twilio-whatsapp-inbound/index.ts | 25 +- 7 files changed, 854 insertions(+), 3 deletions(-) create mode 100644 database-novo/migrations/20260423000007_conversation_bot.sql create mode 100644 src/layout/configuracoes/ConfiguracoesConversasBotsPage.vue diff --git a/database-novo/migrations/20260423000007_conversation_bot.sql b/database-novo/migrations/20260423000007_conversation_bot.sql new file mode 100644 index 0000000..857cf0e --- /dev/null +++ b/database-novo/migrations/20260423000007_conversation_bot.sql @@ -0,0 +1,182 @@ +-- ========================================================================== +-- Agencia PSI — Migracao: Bot de auto-triagem WhatsApp (Grupo 3.7) +-- ========================================================================== +-- Criado por: Leonardo Nohama +-- Data: 2026-04-23 · Sao Carlos/SP — Brasil +-- +-- Bot que coleta nome + motivo + preferencias antes de encaminhar o +-- paciente pro fluxo humano. Evita que terapeuta tenha que fazer +-- perguntas basicas toda vez que chega um lead novo no WhatsApp. +-- +-- Modelo: +-- conversation_bots → config (1 por tenant) +-- conversation_bot_sessions → estado ativo por thread +-- +-- Fluxo: +-- 1. Paciente novo manda inbound +-- 2. Edge evolution-whatsapp-inbound cria session no step 0 e envia +-- greeting + primeira pergunta (steps[0].prompt) +-- 3. Proxima inbound: salva resposta em collected_data[steps[0].variable], +-- avanca step, envia proxima pergunta +-- 4. Quando passa do ultimo step: envia closing_message, marca session +-- como 'completed', cria conversation_note com resumo das respostas +-- +-- Interrupcoes: +-- - Paciente pede opt-out (keywords) → bot sai, session 'opted_out' +-- - Humano assume a conversa (conversation_assignments) → bot sai, 'abandoned_manual' +-- - Session sem resposta > idle_timeout → 'abandoned_idle' (job futuro) +-- +-- UNIQUE parcial garante 1 sessao ativa por thread. +-- ========================================================================== + +-- --------------------------------------------------------------------------- +-- Tabela: conversation_bots (config) +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS public.conversation_bots ( + tenant_id UUID PRIMARY KEY REFERENCES public.tenants(id) ON DELETE CASCADE, + + enabled BOOLEAN NOT NULL DEFAULT false, + + greeting_message TEXT NOT NULL DEFAULT 'Olá! 👋 Sou o assistente virtual. Vou te fazer algumas perguntas rápidas pra a equipe preparar seu atendimento.', + closing_message TEXT NOT NULL DEFAULT 'Obrigado! Recebemos suas informações e a equipe entrará em contato em breve. 💙', + + -- Array de steps: [{ "prompt": "...", "variable": "...", "type": "text" }] + steps JSONB NOT NULL DEFAULT jsonb_build_array( + jsonb_build_object('prompt', 'Qual seu nome completo?', 'variable', 'nome_completo', 'type', 'text'), + jsonb_build_object('prompt', 'O que te levou a buscar atendimento? Pode me contar brevemente.', 'variable', 'motivo', 'type', 'text'), + jsonb_build_object('prompt', 'Prefere atendimento online ou presencial?', 'variable', 'modalidade', 'type', 'text'), + jsonb_build_object('prompt', 'Qual o melhor dia e horário pra você? (Ex: terça à tarde)', 'variable', 'horario_preferido', 'type', 'text') + ), + + -- Gatilho: quem dispara o bot? + trigger_mode TEXT NOT NULL DEFAULT 'new_contact' + CHECK (trigger_mode IN ('new_contact', 'all_unassigned', 'keyword')), + -- Usado quando trigger_mode='keyword' + trigger_keywords TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[], + + -- Abandono automatico: se session fica ativa sem avancar por N min + idle_timeout_minutes INT NOT NULL DEFAULT 30 CHECK (idle_timeout_minutes >= 5 AND idle_timeout_minutes <= 1440), + + -- Se bot deve encerrar quando paciente usa keyword de opt-out + respect_optout BOOLEAN NOT NULL DEFAULT true, + + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +DROP TRIGGER IF EXISTS trg_conv_bots_updated_at ON public.conversation_bots; +CREATE TRIGGER trg_conv_bots_updated_at + BEFORE UPDATE ON public.conversation_bots + FOR EACH ROW EXECUTE FUNCTION public.set_updated_at(); + +COMMENT ON TABLE public.conversation_bots IS + 'Config do bot de triagem WhatsApp por tenant. steps contem array de perguntas.'; + + +-- --------------------------------------------------------------------------- +-- Tabela: conversation_bot_sessions (estado) +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS public.conversation_bot_sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE, + thread_key TEXT NOT NULL, + contact_number TEXT, + + current_step INT NOT NULL DEFAULT 0, + collected_data JSONB NOT NULL DEFAULT '{}'::jsonb, + + status TEXT NOT NULL DEFAULT 'active' + CHECK (status IN ('active', 'completed', 'abandoned_idle', 'abandoned_manual', 'opted_out')), + + started_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_advance_at TIMESTAMPTZ NOT NULL DEFAULT now(), + completed_at TIMESTAMPTZ, + abandoned_at TIMESTAMPTZ, + + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +DROP TRIGGER IF EXISTS trg_bot_sessions_updated_at ON public.conversation_bot_sessions; +CREATE TRIGGER trg_bot_sessions_updated_at + BEFORE UPDATE ON public.conversation_bot_sessions + FOR EACH ROW EXECUTE FUNCTION public.set_updated_at(); + +-- 1 sessao ativa por thread +CREATE UNIQUE INDEX IF NOT EXISTS uq_bot_sessions_active_per_thread + ON public.conversation_bot_sessions (tenant_id, thread_key) + WHERE status = 'active'; + +CREATE INDEX IF NOT EXISTS idx_bot_sessions_tenant_status + ON public.conversation_bot_sessions (tenant_id, status, started_at DESC); + +COMMENT ON TABLE public.conversation_bot_sessions IS + 'Estado do bot por thread. UNIQUE parcial garante 1 ativa por (tenant, thread).'; + + +-- --------------------------------------------------------------------------- +-- RLS +-- --------------------------------------------------------------------------- +ALTER TABLE public.conversation_bots ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS "conv_bots: select membros" ON public.conversation_bots; +CREATE POLICY "conv_bots: select membros" + ON public.conversation_bots + FOR SELECT TO authenticated + USING ( + public.is_saas_admin() + OR EXISTS ( + SELECT 1 FROM public.tenant_members tm + WHERE tm.tenant_id = conversation_bots.tenant_id + AND tm.user_id = auth.uid() + AND tm.status = 'active' + ) + ); + +DROP POLICY IF EXISTS "conv_bots: write admins" ON public.conversation_bots; +CREATE POLICY "conv_bots: write admins" + ON public.conversation_bots + FOR ALL TO authenticated + USING ( + public.is_saas_admin() + OR EXISTS ( + SELECT 1 FROM public.tenant_members tm + WHERE tm.tenant_id = conversation_bots.tenant_id + AND tm.user_id = auth.uid() + AND tm.role IN ('clinic_admin', 'tenant_admin') + AND tm.status = 'active' + ) + ) + WITH CHECK ( + public.is_saas_admin() + OR EXISTS ( + SELECT 1 FROM public.tenant_members tm + WHERE tm.tenant_id = conversation_bots.tenant_id + AND tm.user_id = auth.uid() + AND tm.role IN ('clinic_admin', 'tenant_admin') + AND tm.status = 'active' + ) + ); + + +ALTER TABLE public.conversation_bot_sessions ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS "bot_sessions: select membros" ON public.conversation_bot_sessions; +CREATE POLICY "bot_sessions: select membros" + ON public.conversation_bot_sessions + FOR SELECT TO authenticated + USING ( + public.is_saas_admin() + OR EXISTS ( + SELECT 1 FROM public.tenant_members tm + WHERE tm.tenant_id = conversation_bot_sessions.tenant_id + AND tm.user_id = auth.uid() + AND tm.status = 'active' + ) + ); + +DROP POLICY IF EXISTS "bot_sessions: write service_role" ON public.conversation_bot_sessions; +CREATE POLICY "bot_sessions: write service_role" + ON public.conversation_bot_sessions + FOR ALL TO service_role + USING (true) WITH CHECK (true); diff --git a/src/layout/ConfiguracoesPage.vue b/src/layout/ConfiguracoesPage.vue index edd758f..5df2381 100644 --- a/src/layout/ConfiguracoesPage.vue +++ b/src/layout/ConfiguracoesPage.vue @@ -165,6 +165,13 @@ const grupos = [ icon: 'pi pi-stopwatch', to: '/configuracoes/conversas-sla' }, + { + key: 'conversas-bots', + label: 'Bot de triagem', + desc: 'Coleta nome e motivo do paciente via WhatsApp antes de entrar no fluxo humano.', + icon: 'pi pi-android', + to: '/configuracoes/conversas-bots' + }, { key: 'lembretes-sessao', label: 'Lembretes de Sessão', diff --git a/src/layout/configuracoes/ConfiguracoesConversasBotsPage.vue b/src/layout/configuracoes/ConfiguracoesConversasBotsPage.vue new file mode 100644 index 0000000..a37a9cd --- /dev/null +++ b/src/layout/configuracoes/ConfiguracoesConversasBotsPage.vue @@ -0,0 +1,399 @@ + + + +