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:
Leonardo
2026-04-23 13:54:53 -03:00
parent 4e4bac622c
commit c2c42a1620
7 changed files with 854 additions and 3 deletions
@@ -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);
+7
View File
@@ -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',
@@ -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. 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>
+5
View File
@@ -153,6 +153,11 @@ export default {
name: 'ConfiguracoesConversasSla',
component: () => import('@/layout/configuracoes/ConfiguracoesConversasSlaPage.vue')
},
{
path: 'conversas-bots',
name: 'ConfiguracoesConversasBots',
component: () => import('@/layout/configuracoes/ConfiguracoesConversasBotsPage.vue')
},
{
path: 'lembretes-sessao',
name: 'ConfiguracoesLembretesSessao',
@@ -369,3 +369,223 @@ export function makeTwilioCreditedSendFn(
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,
detectOptoutKeyword,
maybeOptIn,
maybeProcessBot,
maybeSendAutoReply,
registerOptout,
type SendFn
@@ -461,8 +462,21 @@ Deno.serve(async (req: Request) => {
}
}
let botResult: { processed: boolean; status?: string; step?: number; reason?: string } | null = null
if (optoutAction !== 'out') {
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 {
botResult = await maybeProcessBot(supabase, tenantId, threadKey, patientId, fromPhone, cleanBody, sendFn)
} catch (err) {
console.error('[bot] unexpected error:', err)
}
if (!botResult?.processed) {
try {
autoReplyResult = await maybeSendAutoReply(supabase, tenantId, threadKey, fromPhone, 'evolution', sendFn)
} catch (err) {
@@ -470,6 +484,7 @@ Deno.serve(async (req: Request) => {
}
}
}
}
return new Response(JSON.stringify({ ok: true, mediaError, optoutAction, autoReply: autoReplyResult }), {
status: 200,
@@ -25,6 +25,7 @@ import {
buildThreadKey,
detectOptoutKeyword,
maybeOptIn,
maybeProcessBot,
maybeSendAutoReply,
makeTwilioCreditedSendFn,
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') {
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,