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>
This commit is contained in:
@@ -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);
|
||||||
@@ -165,6 +165,13 @@ const grupos = [
|
|||||||
icon: 'pi pi-stopwatch',
|
icon: 'pi pi-stopwatch',
|
||||||
to: '/configuracoes/conversas-sla'
|
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',
|
key: 'lembretes-sessao',
|
||||||
label: 'Lembretes de Sessão',
|
label: 'Lembretes de Sessão',
|
||||||
|
|||||||
@@ -0,0 +1,399 @@
|
|||||||
|
<!--
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Agência PSI — Bot de triagem WhatsApp (CRM Grupo 3.7)
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Configura bot que coleta nome, motivo e preferências antes do paciente
|
||||||
|
| entrar no fluxo humano. Resposta armazenada em conversation_notes pra
|
||||||
|
| terapeuta ver ao assumir a conversa.
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
-->
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted, watch } from 'vue';
|
||||||
|
import { useToast } from 'primevue/usetoast';
|
||||||
|
import { useConfirm } from 'primevue/useconfirm';
|
||||||
|
import { useTenantStore } from '@/stores/tenantStore';
|
||||||
|
import { supabase } from '@/lib/supabase/client';
|
||||||
|
|
||||||
|
const toast = useToast();
|
||||||
|
const confirm = useConfirm();
|
||||||
|
const tenantStore = useTenantStore();
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG = () => ({
|
||||||
|
enabled: false,
|
||||||
|
greeting_message: 'Olá! 👋 Sou o assistente virtual. Vou te fazer algumas perguntas rápidas pra a equipe preparar seu atendimento.',
|
||||||
|
closing_message: 'Obrigado! Recebemos suas informações e a equipe entrará em contato em breve. 💙',
|
||||||
|
steps: [
|
||||||
|
{ prompt: 'Qual seu nome completo?', variable: 'nome_completo', type: 'text' },
|
||||||
|
{ prompt: 'O que te levou a buscar atendimento? Pode me contar brevemente.', variable: 'motivo', type: 'text' },
|
||||||
|
{ prompt: 'Prefere atendimento online ou presencial?', variable: 'modalidade', type: 'text' },
|
||||||
|
{ prompt: 'Qual o melhor dia e horário pra você? (Ex: terça à tarde)', variable: 'horario_preferido', type: 'text' }
|
||||||
|
],
|
||||||
|
trigger_mode: 'new_contact',
|
||||||
|
trigger_keywords: [],
|
||||||
|
idle_timeout_minutes: 30,
|
||||||
|
respect_optout: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const TRIGGER_OPTIONS = [
|
||||||
|
{ value: 'new_contact', label: 'Primeiro contato (novo número sem paciente cadastrado)' },
|
||||||
|
{ value: 'all_unassigned', label: 'Toda conversa sem terapeuta atribuído' },
|
||||||
|
{ value: 'keyword', label: 'Quando paciente digita palavra-chave' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const config = ref(DEFAULT_CONFIG());
|
||||||
|
const loading = ref(false);
|
||||||
|
const saving = ref(false);
|
||||||
|
|
||||||
|
const sessions = ref([]);
|
||||||
|
const sessionsLoading = ref(false);
|
||||||
|
|
||||||
|
const tenantId = computed(() => tenantStore.activeTenantId);
|
||||||
|
|
||||||
|
async function loadConfig() {
|
||||||
|
if (!tenantId.value) return;
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('conversation_bots')
|
||||||
|
.select('*')
|
||||||
|
.eq('tenant_id', tenantId.value)
|
||||||
|
.maybeSingle();
|
||||||
|
if (error) throw error;
|
||||||
|
if (data) {
|
||||||
|
config.value = {
|
||||||
|
enabled: !!data.enabled,
|
||||||
|
greeting_message: data.greeting_message || '',
|
||||||
|
closing_message: data.closing_message || '',
|
||||||
|
steps: Array.isArray(data.steps) && data.steps.length
|
||||||
|
? data.steps.map((s) => ({ prompt: s.prompt || '', variable: s.variable || '', type: s.type || 'text' }))
|
||||||
|
: DEFAULT_CONFIG().steps,
|
||||||
|
trigger_mode: data.trigger_mode || 'new_contact',
|
||||||
|
trigger_keywords: Array.isArray(data.trigger_keywords) ? [...data.trigger_keywords] : [],
|
||||||
|
idle_timeout_minutes: Number(data.idle_timeout_minutes) || 30,
|
||||||
|
respect_optout: data.respect_optout !== false
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
config.value = DEFAULT_CONFIG();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast.add({ severity: 'error', summary: 'Erro ao carregar config', detail: e.message, life: 4000 });
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addStep() {
|
||||||
|
config.value.steps.push({ prompt: '', variable: `pergunta_${config.value.steps.length + 1}`, type: 'text' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeStep(idx) {
|
||||||
|
if (config.value.steps.length === 1) {
|
||||||
|
toast.add({ severity: 'warn', summary: 'Deixe pelo menos 1 pergunta', life: 2500 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
config.value.steps.splice(idx, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveStep(idx, dir) {
|
||||||
|
const newIdx = idx + dir;
|
||||||
|
if (newIdx < 0 || newIdx >= config.value.steps.length) return;
|
||||||
|
const [item] = config.value.steps.splice(idx, 1);
|
||||||
|
config.value.steps.splice(newIdx, 0, item);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validate() {
|
||||||
|
const c = config.value;
|
||||||
|
if (!c.greeting_message?.trim()) {
|
||||||
|
toast.add({ severity: 'warn', summary: 'Mensagem de saudação obrigatória', life: 3000 });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!c.closing_message?.trim()) {
|
||||||
|
toast.add({ severity: 'warn', summary: 'Mensagem de encerramento obrigatória', life: 3000 });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!Array.isArray(c.steps) || c.steps.length === 0) {
|
||||||
|
toast.add({ severity: 'warn', summary: 'Adicione ao menos 1 pergunta', life: 3000 });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (let i = 0; i < c.steps.length; i++) {
|
||||||
|
const s = c.steps[i];
|
||||||
|
if (!s.prompt?.trim()) {
|
||||||
|
toast.add({ severity: 'warn', summary: `Pergunta ${i + 1} sem texto`, life: 3000 });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!/^[a-z_][a-z0-9_]*$/i.test(String(s.variable || '').trim())) {
|
||||||
|
toast.add({ severity: 'warn', summary: `Pergunta ${i + 1}: variável inválida`, detail: 'Use letras, números e _', life: 4000 });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (c.trigger_mode === 'keyword' && c.trigger_keywords.length === 0) {
|
||||||
|
toast.add({ severity: 'warn', summary: 'Adicione ao menos 1 palavra-chave', life: 3000 });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveConfig() {
|
||||||
|
if (!tenantId.value) return;
|
||||||
|
if (!validate()) return;
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
tenant_id: tenantId.value,
|
||||||
|
enabled: !!config.value.enabled,
|
||||||
|
greeting_message: String(config.value.greeting_message || '').trim().slice(0, 1000),
|
||||||
|
closing_message: String(config.value.closing_message || '').trim().slice(0, 1000),
|
||||||
|
steps: config.value.steps.map((s) => ({
|
||||||
|
prompt: String(s.prompt || '').trim().slice(0, 500),
|
||||||
|
variable: String(s.variable || '').trim().toLowerCase(),
|
||||||
|
type: s.type || 'text'
|
||||||
|
})),
|
||||||
|
trigger_mode: config.value.trigger_mode,
|
||||||
|
trigger_keywords: (config.value.trigger_keywords || [])
|
||||||
|
.map((k) => String(k || '').trim().toLowerCase())
|
||||||
|
.filter(Boolean),
|
||||||
|
idle_timeout_minutes: Math.max(5, Math.min(1440, Number(config.value.idle_timeout_minutes) || 30)),
|
||||||
|
respect_optout: !!config.value.respect_optout
|
||||||
|
};
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('conversation_bots')
|
||||||
|
.upsert(payload, { onConflict: 'tenant_id' });
|
||||||
|
if (error) throw error;
|
||||||
|
toast.add({ severity: 'success', summary: 'Configuração salva', life: 2500 });
|
||||||
|
loadSessions();
|
||||||
|
} catch (e) {
|
||||||
|
toast.add({ severity: 'error', summary: 'Erro ao salvar', detail: e.message, life: 4000 });
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSessions() {
|
||||||
|
if (!tenantId.value) return;
|
||||||
|
sessionsLoading.value = true;
|
||||||
|
try {
|
||||||
|
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 3600 * 1000).toISOString();
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('conversation_bot_sessions')
|
||||||
|
.select('id, thread_key, contact_number, current_step, collected_data, status, started_at, completed_at, abandoned_at')
|
||||||
|
.eq('tenant_id', tenantId.value)
|
||||||
|
.gte('started_at', sevenDaysAgo)
|
||||||
|
.order('started_at', { ascending: false })
|
||||||
|
.limit(30);
|
||||||
|
if (error) throw error;
|
||||||
|
sessions.value = data || [];
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[bots] loadSessions:', e?.message);
|
||||||
|
} finally {
|
||||||
|
sessionsLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusTag(s) {
|
||||||
|
const map = {
|
||||||
|
active: { label: 'Em andamento', severity: 'warn' },
|
||||||
|
completed: { label: 'Concluída', severity: 'success' },
|
||||||
|
abandoned_idle: { label: 'Abandonada', severity: 'secondary' },
|
||||||
|
abandoned_manual: { label: 'Assumida por humano', severity: 'info' },
|
||||||
|
opted_out: { label: 'Opt-out', severity: 'danger' }
|
||||||
|
};
|
||||||
|
return map[s] || { label: s, severity: 'secondary' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso) {
|
||||||
|
if (!iso) return '—';
|
||||||
|
return new Date(iso).toLocaleString('pt-BR', { dateStyle: 'short', timeStyle: 'short' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function addKeyword() {
|
||||||
|
const input = prompt('Palavra-chave (ex: "agendar", "consulta"):');
|
||||||
|
if (!input) return;
|
||||||
|
const kw = String(input).trim().toLowerCase();
|
||||||
|
if (kw && !config.value.trigger_keywords.includes(kw)) {
|
||||||
|
config.value.trigger_keywords.push(kw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeKeyword(idx) {
|
||||||
|
config.value.trigger_keywords.splice(idx, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetToDefault() {
|
||||||
|
confirm.require({
|
||||||
|
message: 'Restaurar perguntas e mensagens pro modelo padrão? Isso não afeta sessões em andamento.',
|
||||||
|
header: 'Restaurar padrão',
|
||||||
|
icon: 'pi pi-undo',
|
||||||
|
accept: () => {
|
||||||
|
const d = DEFAULT_CONFIG();
|
||||||
|
config.value.greeting_message = d.greeting_message;
|
||||||
|
config.value.closing_message = d.closing_message;
|
||||||
|
config.value.steps = d.steps;
|
||||||
|
toast.add({ severity: 'info', summary: 'Restaurado — lembre de salvar', life: 2500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadConfig();
|
||||||
|
await loadSessions();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(() => tenantStore.activeTenantId, async (id) => {
|
||||||
|
if (id) {
|
||||||
|
await loadConfig();
|
||||||
|
await loadSessions();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<ConfirmDialog />
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="cfg-subheader">
|
||||||
|
<div class="cfg-subheader__icon"><i class="pi pi-android" /></div>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="cfg-subheader__title">Bot de triagem WhatsApp</div>
|
||||||
|
<div class="cfg-subheader__sub">Coleta nome, motivo e preferências do paciente antes de entrar no fluxo humano.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="flex justify-center py-10">
|
||||||
|
<ProgressSpinner style="width: 40px; height: 40px" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<!-- Card principal -->
|
||||||
|
<div class="border border-[var(--surface-border)] rounded-lg bg-[var(--surface-card)] p-4 flex flex-col gap-4">
|
||||||
|
<!-- Toggle -->
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<ToggleSwitch v-model="config.enabled" inputId="bot-enabled" />
|
||||||
|
<label for="bot-enabled" class="text-sm font-semibold cursor-pointer">Ativar bot</label>
|
||||||
|
</div>
|
||||||
|
<Tag :value="config.enabled ? 'Ativo' : 'Desativado'" :severity="config.enabled ? 'success' : 'secondary'" class="text-[0.7rem]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Trigger -->
|
||||||
|
<div class="flex flex-col gap-1 border-t border-[var(--surface-border)] pt-3">
|
||||||
|
<label class="text-xs font-semibold uppercase tracking-wide text-[var(--text-color-secondary)]">Quando acionar o bot</label>
|
||||||
|
<Select v-model="config.trigger_mode" :options="TRIGGER_OPTIONS" optionLabel="label" optionValue="value" class="w-full" />
|
||||||
|
|
||||||
|
<!-- Keywords quando modo=keyword -->
|
||||||
|
<div v-if="config.trigger_mode === 'keyword'" class="mt-2 flex flex-col gap-1.5">
|
||||||
|
<label class="text-xs text-[var(--text-color-secondary)]">Palavras-chave que ativam o bot</label>
|
||||||
|
<div class="flex flex-wrap gap-1.5">
|
||||||
|
<span v-for="(kw, idx) in config.trigger_keywords" :key="kw + idx"
|
||||||
|
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-[var(--surface-ground)] border border-[var(--surface-border)] text-xs">
|
||||||
|
{{ kw }}
|
||||||
|
<button class="text-[var(--text-color-secondary)] hover:text-red-500" @click="removeKeyword(idx)">
|
||||||
|
<i class="pi pi-times text-[0.6rem]" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
<Button icon="pi pi-plus" label="Adicionar" size="small" severity="secondary" outlined @click="addKeyword" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mensagem de saudação -->
|
||||||
|
<div class="flex flex-col gap-1 border-t border-[var(--surface-border)] pt-3">
|
||||||
|
<label class="text-xs font-semibold uppercase tracking-wide text-[var(--text-color-secondary)]">Saudação inicial</label>
|
||||||
|
<Textarea v-model="config.greeting_message" rows="2" autoResize maxlength="1000" class="w-full" />
|
||||||
|
<small class="text-[var(--text-color-secondary)]">Enviada uma vez quando o bot inicia uma sessão.</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Perguntas -->
|
||||||
|
<div class="flex flex-col gap-2 border-t border-[var(--surface-border)] pt-3">
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<label class="text-xs font-semibold uppercase tracking-wide text-[var(--text-color-secondary)]">Perguntas (em ordem)</label>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<Button icon="pi pi-undo" label="Padrão" size="small" severity="secondary" outlined @click="resetToDefault" />
|
||||||
|
<Button icon="pi pi-plus" label="Nova pergunta" size="small" @click="addStep" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-for="(step, idx) in config.steps" :key="idx"
|
||||||
|
class="border border-[var(--surface-border)] rounded-md p-3 flex flex-col gap-2 bg-[var(--surface-ground)]">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="font-mono text-xs px-2 py-0.5 rounded bg-[var(--surface-card)] border border-[var(--surface-border)]">{{ idx + 1 }}</span>
|
||||||
|
<div class="flex gap-1 ml-auto">
|
||||||
|
<Button icon="pi pi-arrow-up" size="small" text rounded :disabled="idx === 0" @click="moveStep(idx, -1)" />
|
||||||
|
<Button icon="pi pi-arrow-down" size="small" text rounded :disabled="idx === config.steps.length - 1" @click="moveStep(idx, 1)" />
|
||||||
|
<Button icon="pi pi-trash" size="small" text rounded severity="danger" @click="removeStep(idx)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label class="text-[0.65rem] uppercase text-[var(--text-color-secondary)]">Pergunta</label>
|
||||||
|
<Textarea v-model="step.prompt" rows="2" autoResize maxlength="500" />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label class="text-[0.65rem] uppercase text-[var(--text-color-secondary)]">Variável (identificador)</label>
|
||||||
|
<InputText v-model="step.variable" class="!text-xs font-mono" maxlength="50" />
|
||||||
|
<small class="text-[var(--text-color-secondary)]">Usada na nota interna e no extrato. Só letras/números/_</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mensagem de encerramento -->
|
||||||
|
<div class="flex flex-col gap-1 border-t border-[var(--surface-border)] pt-3">
|
||||||
|
<label class="text-xs font-semibold uppercase tracking-wide text-[var(--text-color-secondary)]">Encerramento</label>
|
||||||
|
<Textarea v-model="config.closing_message" rows="2" autoResize maxlength="1000" class="w-full" />
|
||||||
|
<small class="text-[var(--text-color-secondary)]">Enviada quando o paciente responde a última pergunta. Depois disso a conversa entra no fluxo humano.</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Opções avançadas -->
|
||||||
|
<div class="flex flex-col gap-3 border-t border-[var(--surface-border)] pt-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label class="text-sm whitespace-nowrap">Abandonar se paciente não responder em</label>
|
||||||
|
<InputNumber v-model="config.idle_timeout_minutes" :min="5" :max="1440" showButtons buttonLayout="horizontal" :inputStyle="{ width: '4rem', textAlign: 'center' }" incrementButtonIcon="pi pi-plus" decrementButtonIcon="pi pi-minus" />
|
||||||
|
<span class="text-sm text-[var(--text-color-secondary)]">min</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<ToggleSwitch v-model="config.respect_optout" inputId="bot-optout" />
|
||||||
|
<label for="bot-optout" class="text-sm cursor-pointer flex-1">
|
||||||
|
<span class="font-semibold">Respeitar opt-out durante o bot</span>
|
||||||
|
<span class="block text-xs text-[var(--text-color-secondary)]">Se o paciente usar palavra de opt-out, bot encerra e respeita a opção.</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Save -->
|
||||||
|
<div class="flex justify-end border-t border-[var(--surface-border)] pt-3">
|
||||||
|
<Button label="Salvar configuração" icon="pi pi-save" :loading="saving" class="rounded-full" @click="saveConfig" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Histórico de sessões -->
|
||||||
|
<div class="border border-[var(--surface-border)] rounded-lg bg-[var(--surface-card)] p-4 flex flex-col gap-3">
|
||||||
|
<div class="flex items-center justify-between gap-2 flex-wrap">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<i class="pi pi-history text-[var(--primary-color)]" />
|
||||||
|
<h3 class="text-sm font-bold uppercase tracking-wide m-0">Sessões dos últimos 7 dias</h3>
|
||||||
|
<Tag :value="sessions.length" severity="secondary" class="text-[0.65rem]" />
|
||||||
|
</div>
|
||||||
|
<Button icon="pi pi-refresh" size="small" severity="secondary" outlined :loading="sessionsLoading" @click="loadSessions" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="sessionsLoading" class="text-xs text-[var(--text-color-secondary)] italic py-3 text-center">Carregando…</div>
|
||||||
|
<div v-else-if="!sessions.length" class="text-xs text-[var(--text-color-secondary)] italic py-4 text-center">
|
||||||
|
Nenhuma sessão ainda. Ative o bot e espere o próximo paciente novo enviar mensagem.
|
||||||
|
</div>
|
||||||
|
<div v-else class="flex flex-col gap-1 max-h-[360px] overflow-y-auto text-xs">
|
||||||
|
<div v-for="s in sessions" :key="s.id"
|
||||||
|
class="grid grid-cols-[auto_auto_1fr_auto_auto] items-center gap-3 px-2 py-1.5 rounded hover:bg-[var(--surface-hover)]">
|
||||||
|
<Tag :value="statusTag(s.status).label" :severity="statusTag(s.status).severity" class="text-[0.6rem]" />
|
||||||
|
<span class="font-mono text-[0.65rem] text-[var(--text-color-secondary)]">{{ s.contact_number || s.thread_key }}</span>
|
||||||
|
<span class="text-[var(--text-color-secondary)] truncate">
|
||||||
|
<template v-if="s.collected_data?.nome_completo">{{ s.collected_data.nome_completo }}</template>
|
||||||
|
<template v-else-if="s.collected_data?.motivo">{{ s.collected_data.motivo }}</template>
|
||||||
|
<template v-else>—</template>
|
||||||
|
</span>
|
||||||
|
<span class="text-[var(--text-color-secondary)] whitespace-nowrap">step {{ s.current_step }}/{{ config.steps.length }}</span>
|
||||||
|
<span class="text-[var(--text-color-secondary)] font-mono whitespace-nowrap">{{ formatDate(s.started_at) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -153,6 +153,11 @@ export default {
|
|||||||
name: 'ConfiguracoesConversasSla',
|
name: 'ConfiguracoesConversasSla',
|
||||||
component: () => import('@/layout/configuracoes/ConfiguracoesConversasSlaPage.vue')
|
component: () => import('@/layout/configuracoes/ConfiguracoesConversasSlaPage.vue')
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'conversas-bots',
|
||||||
|
name: 'ConfiguracoesConversasBots',
|
||||||
|
component: () => import('@/layout/configuracoes/ConfiguracoesConversasBotsPage.vue')
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'lembretes-sessao',
|
path: 'lembretes-sessao',
|
||||||
name: 'ConfiguracoesLembretesSessao',
|
name: 'ConfiguracoesLembretesSessao',
|
||||||
|
|||||||
@@ -369,3 +369,223 @@ export function makeTwilioCreditedSendFn(
|
|||||||
return { ok: true, messageId: sendRes.messageId ?? null }
|
return { ok: true, messageId: sendRes.messageId ?? null }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
// Bot de auto-triagem (3.7)
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
type BotStep = { prompt: string; variable: string; type?: string }
|
||||||
|
type BotConfig = {
|
||||||
|
enabled: boolean
|
||||||
|
greeting_message: string
|
||||||
|
closing_message: string
|
||||||
|
steps: BotStep[]
|
||||||
|
trigger_mode: 'new_contact' | 'all_unassigned' | 'keyword'
|
||||||
|
trigger_keywords: string[]
|
||||||
|
idle_timeout_minutes: number
|
||||||
|
respect_optout: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifica se o bot deve processar esta inbound. Se sim, avança/inicia
|
||||||
|
* a sessão e envia a próxima pergunta. Retorna true se processou (caller
|
||||||
|
* deve skipar auto-reply pra não empilhar respostas).
|
||||||
|
*
|
||||||
|
* Caller garante que esta função só é chamada quando:
|
||||||
|
* - mensagem é inbound (não fromMe)
|
||||||
|
* - não é opt-out
|
||||||
|
*/
|
||||||
|
export async function maybeProcessBot(
|
||||||
|
supa: SupabaseClient,
|
||||||
|
tenantId: string,
|
||||||
|
threadKey: string,
|
||||||
|
patientId: string | null,
|
||||||
|
phone: string,
|
||||||
|
body: string,
|
||||||
|
sendFn: SendFn
|
||||||
|
): Promise<{ processed: boolean; status?: string; step?: number; reason?: string }> {
|
||||||
|
const text = String(body || '').trim()
|
||||||
|
|
||||||
|
// Carrega config
|
||||||
|
const { data: cfg } = await supa
|
||||||
|
.from('conversation_bots')
|
||||||
|
.select('*')
|
||||||
|
.eq('tenant_id', tenantId)
|
||||||
|
.maybeSingle()
|
||||||
|
|
||||||
|
if (!cfg || !cfg.enabled) return { processed: false, reason: 'disabled' }
|
||||||
|
const config = cfg as BotConfig
|
||||||
|
if (!Array.isArray(config.steps) || config.steps.length === 0) {
|
||||||
|
return { processed: false, reason: 'no_steps' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Busca sessão ativa
|
||||||
|
const { data: active } = await supa
|
||||||
|
.from('conversation_bot_sessions')
|
||||||
|
.select('*')
|
||||||
|
.eq('tenant_id', tenantId)
|
||||||
|
.eq('thread_key', threadKey)
|
||||||
|
.eq('status', 'active')
|
||||||
|
.maybeSingle()
|
||||||
|
|
||||||
|
if (active) {
|
||||||
|
// Se humano já atribuiu a thread, abandona bot
|
||||||
|
const { data: assign } = await supa
|
||||||
|
.from('conversation_assignments')
|
||||||
|
.select('assigned_to')
|
||||||
|
.eq('tenant_id', tenantId)
|
||||||
|
.eq('thread_key', threadKey)
|
||||||
|
.maybeSingle()
|
||||||
|
if (assign?.assigned_to) {
|
||||||
|
await supa.from('conversation_bot_sessions')
|
||||||
|
.update({ status: 'abandoned_manual', abandoned_at: new Date().toISOString() })
|
||||||
|
.eq('id', active.id)
|
||||||
|
return { processed: false, reason: 'human_took_over' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return await advanceSession(supa, config, active, text, phone, sendFn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sem sessão ativa — decide se inicia
|
||||||
|
if (config.trigger_mode === 'new_contact') {
|
||||||
|
// Inicia só se ainda não existe nenhuma sessão (completada ou abandonada) pra essa thread
|
||||||
|
const { data: prev } = await supa
|
||||||
|
.from('conversation_bot_sessions')
|
||||||
|
.select('id')
|
||||||
|
.eq('tenant_id', tenantId)
|
||||||
|
.eq('thread_key', threadKey)
|
||||||
|
.limit(1)
|
||||||
|
.maybeSingle()
|
||||||
|
if (prev) return { processed: false, reason: 'already_bot_history' }
|
||||||
|
// Também pula se paciente já existe (é contato conhecido)
|
||||||
|
if (patientId) return { processed: false, reason: 'known_patient' }
|
||||||
|
} else if (config.trigger_mode === 'keyword') {
|
||||||
|
const normalized = normalizeForMatch(text)
|
||||||
|
const matched = (config.trigger_keywords || []).some((kw) => normalized.includes(normalizeForMatch(kw)))
|
||||||
|
if (!matched) return { processed: false, reason: 'no_keyword_match' }
|
||||||
|
}
|
||||||
|
// 'all_unassigned' passa direto
|
||||||
|
|
||||||
|
// Inicia nova sessão
|
||||||
|
return await startSession(supa, config, tenantId, threadKey, phone, sendFn)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startSession(
|
||||||
|
supa: SupabaseClient,
|
||||||
|
config: BotConfig,
|
||||||
|
tenantId: string,
|
||||||
|
threadKey: string,
|
||||||
|
phone: string,
|
||||||
|
sendFn: SendFn
|
||||||
|
): Promise<{ processed: boolean; status?: string; step?: number }> {
|
||||||
|
const { data: session, error: sessErr } = await supa
|
||||||
|
.from('conversation_bot_sessions')
|
||||||
|
.insert({
|
||||||
|
tenant_id: tenantId,
|
||||||
|
thread_key: threadKey,
|
||||||
|
contact_number: phone,
|
||||||
|
current_step: 0,
|
||||||
|
collected_data: {},
|
||||||
|
status: 'active'
|
||||||
|
})
|
||||||
|
.select('id')
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (sessErr || !session) return { processed: false }
|
||||||
|
|
||||||
|
// Manda greeting + primeira pergunta como 2 mensagens separadas
|
||||||
|
const firstPrompt = config.steps[0]?.prompt || ''
|
||||||
|
await sendFn(phone, config.greeting_message)
|
||||||
|
if (firstPrompt) await sendFn(phone, firstPrompt)
|
||||||
|
|
||||||
|
return { processed: true, status: 'started', step: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function advanceSession(
|
||||||
|
supa: SupabaseClient,
|
||||||
|
config: BotConfig,
|
||||||
|
session: { id: string, current_step: number, collected_data: Record<string, unknown>, tenant_id: string, thread_key: string, contact_number: string | null },
|
||||||
|
text: string,
|
||||||
|
phone: string,
|
||||||
|
sendFn: SendFn
|
||||||
|
): Promise<{ processed: boolean; status?: string; step?: number }> {
|
||||||
|
const step = Number(session.current_step) || 0
|
||||||
|
const currentStep = config.steps[step]
|
||||||
|
if (!currentStep) {
|
||||||
|
// Segurança: step fora do range → encerra
|
||||||
|
await supa.from('conversation_bot_sessions')
|
||||||
|
.update({ status: 'completed', completed_at: new Date().toISOString() })
|
||||||
|
.eq('id', session.id)
|
||||||
|
return { processed: true, status: 'completed', step }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Salva resposta do step atual
|
||||||
|
const newData = { ...(session.collected_data || {}), [currentStep.variable]: text }
|
||||||
|
const nextStep = step + 1
|
||||||
|
const isLast = nextStep >= config.steps.length
|
||||||
|
|
||||||
|
if (isLast) {
|
||||||
|
// Finaliza
|
||||||
|
await supa.from('conversation_bot_sessions')
|
||||||
|
.update({
|
||||||
|
collected_data: newData,
|
||||||
|
current_step: nextStep,
|
||||||
|
status: 'completed',
|
||||||
|
completed_at: new Date().toISOString(),
|
||||||
|
last_advance_at: new Date().toISOString()
|
||||||
|
})
|
||||||
|
.eq('id', session.id)
|
||||||
|
|
||||||
|
// Envia closing
|
||||||
|
if (config.closing_message) await sendFn(phone, config.closing_message)
|
||||||
|
|
||||||
|
// Cria nota interna com resumo
|
||||||
|
try {
|
||||||
|
const lines = config.steps.map((s) => {
|
||||||
|
const val = newData[s.variable] ?? '—'
|
||||||
|
return `• ${s.variable}: ${val}`
|
||||||
|
})
|
||||||
|
const summary = `🤖 Triagem automática concluída:\n\n${lines.join('\n')}`
|
||||||
|
await supa.from('conversation_notes').insert({
|
||||||
|
tenant_id: session.tenant_id,
|
||||||
|
thread_key: session.thread_key,
|
||||||
|
contact_number: session.contact_number,
|
||||||
|
body: summary,
|
||||||
|
// created_by obrigatório — usa um user "bot" fictício? Não temos. Pega qualquer admin.
|
||||||
|
created_by: await pickAnyAdmin(supa, session.tenant_id)
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[bot] failed to create summary note:', (err as Error)?.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { processed: true, status: 'completed', step: nextStep }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Avança pra próxima pergunta
|
||||||
|
await supa.from('conversation_bot_sessions')
|
||||||
|
.update({
|
||||||
|
collected_data: newData,
|
||||||
|
current_step: nextStep,
|
||||||
|
last_advance_at: new Date().toISOString()
|
||||||
|
})
|
||||||
|
.eq('id', session.id)
|
||||||
|
|
||||||
|
const nextPrompt = config.steps[nextStep]?.prompt || ''
|
||||||
|
if (nextPrompt) await sendFn(phone, nextPrompt)
|
||||||
|
|
||||||
|
return { processed: true, status: 'advanced', step: nextStep }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pickAnyAdmin(supa: SupabaseClient, tenantId: string): Promise<string> {
|
||||||
|
const { data } = await supa
|
||||||
|
.from('tenant_members')
|
||||||
|
.select('user_id')
|
||||||
|
.eq('tenant_id', tenantId)
|
||||||
|
.in('role', ['tenant_admin', 'clinic_admin'])
|
||||||
|
.eq('status', 'active')
|
||||||
|
.limit(1)
|
||||||
|
.maybeSingle()
|
||||||
|
return (data?.user_id as string) ?? '00000000-0000-0000-0000-000000000000'
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
buildThreadKey,
|
buildThreadKey,
|
||||||
detectOptoutKeyword,
|
detectOptoutKeyword,
|
||||||
maybeOptIn,
|
maybeOptIn,
|
||||||
|
maybeProcessBot,
|
||||||
maybeSendAutoReply,
|
maybeSendAutoReply,
|
||||||
registerOptout,
|
registerOptout,
|
||||||
type SendFn
|
type SendFn
|
||||||
@@ -461,12 +462,26 @@ Deno.serve(async (req: Request) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let botResult: { processed: boolean; status?: string; step?: number; reason?: string } | null = null
|
||||||
|
|
||||||
if (optoutAction !== 'out') {
|
if (optoutAction !== 'out') {
|
||||||
const threadKey = buildThreadKey(patientId, fromPhone)
|
const threadKey = buildThreadKey(patientId, fromPhone)
|
||||||
|
|
||||||
|
// Bot de triagem tem precedência sobre auto-reply: se o bot processou
|
||||||
|
// a inbound (iniciou sessão ou avançou step), não manda auto-reply
|
||||||
|
// pra evitar resposta duplicada.
|
||||||
try {
|
try {
|
||||||
autoReplyResult = await maybeSendAutoReply(supabase, tenantId, threadKey, fromPhone, 'evolution', sendFn)
|
botResult = await maybeProcessBot(supabase, tenantId, threadKey, patientId, fromPhone, cleanBody, sendFn)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[auto-reply] unexpected error:', err)
|
console.error('[bot] unexpected error:', err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!botResult?.processed) {
|
||||||
|
try {
|
||||||
|
autoReplyResult = await maybeSendAutoReply(supabase, tenantId, threadKey, fromPhone, 'evolution', sendFn)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[auto-reply] unexpected error:', err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
buildThreadKey,
|
buildThreadKey,
|
||||||
detectOptoutKeyword,
|
detectOptoutKeyword,
|
||||||
maybeOptIn,
|
maybeOptIn,
|
||||||
|
maybeProcessBot,
|
||||||
maybeSendAutoReply,
|
maybeSendAutoReply,
|
||||||
makeTwilioCreditedSendFn,
|
makeTwilioCreditedSendFn,
|
||||||
registerOptout,
|
registerOptout,
|
||||||
@@ -181,8 +182,30 @@ Deno.serve(async (req: Request) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3) Auto-reply fora do horario (so se nao foi opt-out)
|
// 3) Bot de triagem (tem precedência sobre auto-reply)
|
||||||
|
let botProcessed = false
|
||||||
if (optoutAction !== 'out') {
|
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 threadKey = buildThreadKey(patientId, from)
|
||||||
const arSendFn = makeTwilioCreditedSendFn(
|
const arSendFn = makeTwilioCreditedSendFn(
|
||||||
supabase,
|
supabase,
|
||||||
|
|||||||
Reference in New Issue
Block a user