c2c42a1620
Bot que coleta nome, motivo de busca e preferências ANTES do paciente
entrar no fluxo humano. Terapeuta abre a conversa e já encontra
resumo em conversation_notes.
Banco (migration 20260423000007):
- conversation_bots: config 1 por tenant. enabled, greeting/closing
messages, steps (JSONB array de {prompt, variable, type}), trigger_mode
(new_contact | all_unassigned | keyword), trigger_keywords[],
idle_timeout_minutes, respect_optout.
Defaults vêm com 4 perguntas úteis: nome, motivo, modalidade,
horário preferido.
- conversation_bot_sessions: estado por thread. current_step,
collected_data JSONB, status (active | completed | abandoned_idle |
abandoned_manual | opted_out). UNIQUE parcial garante 1 ativa por
(tenant, thread).
- RLS: leitura tenant/saas_admin, escrita admins (config) + service_role
(sessions, só edge altera).
Shared (_shared/whatsapp-hooks.ts):
- maybeProcessBot: carrega config, busca sessão ativa, avança step
com resposta, envia próxima pergunta via SendFn. Ao esgotar steps,
envia closing + cria conversation_notes com resumo das variáveis
coletadas. Se humano assume (conversation_assignments preenchido),
sessão marca 'abandoned_manual' e bot sai.
- Trigger modes:
- 'new_contact' (default): só inicia pra thread sem histórico bot
E sem paciente vinculado (lead real).
- 'all_unassigned': qualquer thread sem assignee.
- 'keyword': matched contra lista; normalizeForMatch já existe.
Integração nos inbound (ambos providers):
- evolution-whatsapp-inbound: chama maybeProcessBot após opt-in/opt-out,
ANTES do auto-reply. Se bot processou, skip auto-reply (senão duas
respostas sobrepostas).
- twilio-whatsapp-inbound: idem, usando makeTwilioCreditedSendFn pra
cobrar crédito de cada mensagem enviada pelo bot.
UI (/configuracoes/conversas-bots):
- Toggle enabled + Select trigger_mode + (se keyword) chips de keywords.
- Textareas greeting/closing.
- Editor de steps: reordenar (up/down), remover, add, editor com prompt
e variable (regex /^[a-z_][a-z0-9_]*$/).
- Botão "Padrão" restaura mensagens/steps default.
- InputNumber idle_timeout + toggle respect_optout.
- Card inferior: últimas 30 sessões (7 dias) com status, contato,
nome coletado (primeiro campo), progresso (step X/N), início.
- Entrada na landing de configurações + rota /configuracoes/conversas-bots.
Caveat conhecido: a resolução de conversation_notes.created_by usa
o primeiro admin ativo do tenant (pickAnyAdmin). Pra uma v2 seria
ideal ter um user "bot" sintético dedicado.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
400 lines
20 KiB
Vue
400 lines
20 KiB
Vue
<!--
|
|
|--------------------------------------------------------------------------
|
|
| 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>
|