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',
|
||||
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. 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',
|
||||
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,12 +462,26 @@ 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 {
|
||||
autoReplyResult = await maybeSendAutoReply(supabase, tenantId, threadKey, fromPhone, 'evolution', sendFn)
|
||||
botResult = await maybeProcessBot(supabase, tenantId, threadKey, patientId, fromPhone, cleanBody, sendFn)
|
||||
} 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,
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user