mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 01:28:34 +08:00
merge: PR #406 (gate de elegibilidade da IA por origem do lead)
Reconciliação com a `main` em 2005aea6. Cinco conflitos, todos resolvidos
preservando os dois lados:
- `workers/ai-sentiment-worker.ts` (conflito com o #499, já mergeado): o guard
de elegibilidade do #406 entra ANTES da resolução de agente por conversa que
a `main` ganhou na issue #486. Os dois efeitos convivem.
- `lib/agent-engine/agent/inbound-turn.ts` e `lib/ai/handoff/orchestrator.ts`:
imports das duas pontas.
- `lib/waha/ingest.ts`: a `main` já silenciava o bot quando um humano responde
pelo celular (`silenciarBotPorRetomadaHumana`, 3h). O #406 generaliza o mesmo
gesto para todos os canais e acrescenta rastro de handoff. Fica o helper do
#406; a unificação das duas semânticas vem no commit seguinte.
- `supabase/baseline.sql`: apêndices somados, o do #406 no fim e renumerado.
Além dos conflitos:
- Migration renumerada 0203 -> 0206 (a `main` já publicou 0203, 0204 e 0205).
Os TRÊS artefatos andaram juntos: arquivo, apêndice do baseline e MANIFEST.
- Jornada renumerada J19 -> J20 (a `main` já usa J19 para "Quem instala em
espanhol"), incluindo as três specs E2E, o `e2e.yml` e os helpers de seed.
É o que reprovava o `verify`.
- Removidos os três documentos de estratégia comercial de terceiro que vieram
na raiz (1.314 linhas com nome de vendedor, ofertas e público-alvo de uma
empresa específica): não têm lugar num produto genérico.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
---
|
||||
impacto: capacidade_nova
|
||||
secao: adicionado
|
||||
titulo: A IA pode ser limitada a atender só leads de origem conhecida
|
||||
---
|
||||
|
||||
Num número de WhatsApp que também é usado para falar com clientes, fornecedores
|
||||
e contatos pessoais, a IA respondia todo mundo assim que um agente era
|
||||
publicado. Agora dá para ligar, por canal, o modo "só atende quem eu autorizei":
|
||||
a IA fica em silêncio por padrão e só assume a conversa quando o lead veio de
|
||||
uma origem elegível — uma submissão nova de formulário, uma campanha
|
||||
identificada, ou uma liberação manual pela tela. Histórico antigo, existência do
|
||||
contato, conversa anterior ou reinício de um worker nunca autorizam sozinhos.
|
||||
|
||||
Esse limite vale para TODOS os caminhos de resposta automática — o motor do
|
||||
agente, o follow-up, o texto fixo de fluxo, o worker de resposta legado e a
|
||||
passagem para humano por sentimento. Não há atalho: nenhum deles envia mensagem
|
||||
de IA para uma conversa não autorizada.
|
||||
|
||||
Além disso, e independentemente desse modo: quando você responde um cliente à
|
||||
mão pelo próprio WhatsApp (celular, ou outra plataforma na mesma conta), a IA
|
||||
para naquela conversa para não responder junto — silêncio durável, que você
|
||||
desfaz devolvendo a conversa ao automático. Isso não apaga a origem do lead.
|
||||
|
||||
Quem não ligar o modo "só atende quem eu autorizei" mantém o comportamento de
|
||||
antes para todo o resto.
|
||||
@@ -152,6 +152,15 @@ AI_BUDGET_ENFORCEMENT=on
|
||||
EVENT_LOG_DRAIN_INTERVAL_MS=2000
|
||||
EVENT_LOG_DRAIN_IDLE_INTERVAL_MS=10000
|
||||
EVENT_LOG_DRAIN_BATCH_SIZE=50
|
||||
|
||||
# --- Elegibilidade da IA por origem do lead ----------------------------------
|
||||
# Vale SÓ nos canais em que você ligou o modo "só atende quem eu autorizei"
|
||||
# (Configurações do canal › ai_gate = allowlist). Nesses canais, um contato fica
|
||||
# elegível para a IA quando vem de uma origem conhecida (formulário do Respondi,
|
||||
# campanha, liberação manual). Este número é por quantos DIAS essa autorização
|
||||
# vale — depois disso, uma submissão antiga não reativa a IA sozinha. Uma
|
||||
# conversa que segue viva renova o prazo a cada turno. Vazio = 21 dias.
|
||||
AI_ALLOWLIST_TTL_DAYS=21
|
||||
# Stub do endpoint de teste do agente: 'true' devolve trace fabricado em vez de
|
||||
# executar o agente. Deixe false — o runtime real já existe. Só ligue para
|
||||
# exercitar o render da UI sem gastar token.
|
||||
|
||||
@@ -178,6 +178,8 @@ jobs:
|
||||
agenda-tela-do-produto.spec.ts
|
||||
notificacoes-diz-o-que-falta.spec.ts
|
||||
relogio-http-cron-externo.spec.ts
|
||||
j20-elegibilidade-respondi.spec.ts j20-elegibilidade-followup.spec.ts
|
||||
j20-elegibilidade-atendimento-manual.spec.ts
|
||||
# ⚠️ A ORDEM DESTA LISTA NÃO DECIDE A ORDEM DE EXECUÇÃO. O Playwright
|
||||
# ordena os arquivos por CAMINHO, não pela ordem em que são passados na
|
||||
# linha de comando. Medido no run 31838253496: `marca-logo.spec.ts` estava
|
||||
|
||||
@@ -32,6 +32,7 @@ import { origemDaPagina, registrarCaptacao } from "@/lib/webhooks/captacao";
|
||||
import { ipDoClienteParaInet } from "@/lib/http/ip-do-cliente";
|
||||
import { decryptWebhookSecret } from "@/lib/webhooks/secrets";
|
||||
import { ApiError } from "@/lib/api/types";
|
||||
import { autorizarContatoParaIA } from "@/lib/ai/elegibilidade/autorizacao";
|
||||
import { kickLocalPipeline } from "@/lib/dev/kick-local-pipeline";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -631,6 +632,25 @@ export async function POST(req: NextRequest, ctx: RouteCtx): Promise<NextRespons
|
||||
outcome: "criado",
|
||||
});
|
||||
|
||||
// ELEGIBILIDADE DA IA (caso 1): uma submissão do Respondi é uma origem
|
||||
// elegível — autoriza o contato a ser atendido automaticamente. Só tem efeito
|
||||
// nos canais com o gate `allowlist` ligado; canal 'open' ignora a coluna.
|
||||
// Consent explicitamente NEGADO não autoriza (LGPD); `not_found` (o formulário
|
||||
// não pergunta) autoriza — mesma régua do `consentDoEnvio` acima.
|
||||
const consentNegado =
|
||||
respondiMapped != null &&
|
||||
respondiMapped.consent.detectedVia !== "not_found" &&
|
||||
!respondiMapped.consent.granted;
|
||||
if (respondiMapped && contactId && !consentNegado) {
|
||||
const formId = respondiMapped.custom_fields.respondi_form_id ?? "form";
|
||||
const submissionId = respondiMapped.custom_fields.respondi_respondent_id ?? "s";
|
||||
await autorizarContatoParaIA(admin, {
|
||||
organizationId: source.organization_id,
|
||||
contactId,
|
||||
reason: `respondi:${formId}:${submissionId}`,
|
||||
});
|
||||
}
|
||||
|
||||
// Captação: drena lead.created e inscreve no fluxo neste mesmo request.
|
||||
// Sem isto, em prod (Vercel Hobby sem cron de 1 min) o gatilho fica pending.
|
||||
await kickLocalPipeline(
|
||||
|
||||
@@ -369,6 +369,22 @@
|
||||
"type": "table",
|
||||
"label": "agent_inbox_items",
|
||||
"sublabel": "kind='handoff' — quem assume lê aqui se o cliente foi avisado"
|
||||
},
|
||||
{
|
||||
"id": "ingestSaida",
|
||||
"lane": "borda",
|
||||
"col": 4,
|
||||
"type": "service",
|
||||
"label": "ingestão do canal — mensagem fromMe/outgoing",
|
||||
"sublabel": "lib/waha/ingest.ts handleOutboundFromUserPhone · lib/channels/zernio/ingest.ts"
|
||||
},
|
||||
{
|
||||
"id": "atendimentoManual",
|
||||
"lane": "regra",
|
||||
"col": 4,
|
||||
"type": "service",
|
||||
"label": "lib/escalacao/atendimento-manual.ts",
|
||||
"sublabel": "pausarIaPorAtendimentoManual — pessoa respondeu por fora do CRM"
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
@@ -749,6 +765,24 @@
|
||||
"from": "reply",
|
||||
"to": "avisoCrm",
|
||||
"label": "'Assumir eu' também avisa antes de calar o automático"
|
||||
},
|
||||
{
|
||||
"id": "e64",
|
||||
"from": "ingestSaida",
|
||||
"to": "atendimentoManual",
|
||||
"label": "resposta manual pelo celular = a pessoa assumiu (eco do próprio envio já saiu como dedup)"
|
||||
},
|
||||
{
|
||||
"id": "e65",
|
||||
"from": "atendimentoManual",
|
||||
"to": "conversas",
|
||||
"label": "bot_silenced_until='infinity' + last_handoff_reason — NÃO toca ai_authorized_at (a origem do lead é outro estado)"
|
||||
},
|
||||
{
|
||||
"id": "e66",
|
||||
"from": "retomada",
|
||||
"to": "atendimentoManual",
|
||||
"label": "'Devolver ao automático' desfaz esta pausa (solta a TRAVA 2, a mesma)"
|
||||
}
|
||||
],
|
||||
"cards": [
|
||||
@@ -783,4 +817,4 @@
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -735,6 +735,87 @@ GitHub dispara no horário é do GitHub.
|
||||
|
||||
---
|
||||
|
||||
## J20 — A IA só atende quem tem origem elegível (gate opt-in por canal) `[P0]`
|
||||
|
||||
**Por que P0:** achado pelo dono do produto num número que é também o WhatsApp
|
||||
pessoal/comercial dele — a IA respondeu automaticamente para cliente atual, dono
|
||||
de incorporadora, contato pessoal, fornecedor e conversa antiga. O
|
||||
DeskcommCRM responde `allow by default` (publicou agente para a sessão → atende
|
||||
todo inbound); num número compartilhado com gente isso é a IA assumindo conversa
|
||||
que não era dela.
|
||||
|
||||
**Contexto do código:** gate OPT-IN por canal —
|
||||
`channel_sessions.metadata.ai_gate = 'allowlist'` (ausente / `'open'` =
|
||||
comportamento de hoje). Com o gate, a IA só responde quando
|
||||
`contacts.ai_authorized_at` está setado (por uma origem elegível) e dentro da
|
||||
janela `AI_ALLOWLIST_TTL_DAYS`. A decisão é `lib/ai/elegibilidade/gate.ts`
|
||||
(pura), consultada pelo drain (`lib/agent-engine/edge/crm/drain.ts`, decide
|
||||
enfileirar) e pelo turno (`lib/agent-engine/agent/inbound-turn.ts`, decide
|
||||
rodar). Origens que autorizam: webhook do Respondi
|
||||
(`app/api/v1/webhooks/in/[token]`), match de campanha na ingestão
|
||||
(`lib/channels/pos-entrada.ts` × `organizations.settings.campanhas_whatsapp`),
|
||||
ação `send_ai_message`, retomada manual (`lib/escalacao/retomada.ts`).
|
||||
|
||||
| # | Caso | Expectativa | Cobertura |
|
||||
|---|---|---|---|
|
||||
| J20.1 | Cliente atual manda "boa noite" (gate allowlist, contato não autorizado) | IA NÃO responde; conversa fica humana | **UNIT** — `gate.test.ts` "teste 1/3/4/9", `drain.test.ts` "gate allowlist + contato NÃO autorizado" |
|
||||
| J20.2 | Cliente atual com conversa aberta, não autorizado | IA NÃO responde (estado da conversa não pesa) | **UNIT** — `gate.test.ts` "teste 2" |
|
||||
| J20.3 | Contato pessoal manda mensagem | IA NÃO responde | **UNIT** — coberto por J20.1 (mesma regra) |
|
||||
| J20.4 | Fornecedor manda proposta comercial | IA NÃO responde automaticamente | **UNIT** — coberto por J20.1 |
|
||||
| J20.5 | Conversa antiga de 3 dias; publicar agente | publicar NÃO dispara nada (`ai_agent.published` não tem consumidor) + o drain pula evento superado por inbound mais recente | **UNIT** — `drain.test.ts` "evento superado por inbound mais recente"; **CÓDIGO** — grep: zero consumidor de `ai_agent.published` |
|
||||
| J20.6 | Nova submissão Respondi → o contato fica elegível | IA pode responder o retorno do lead | **UNIT** — webhook seta `ai_authorized_reason='respondi:<form>:<sub>'`; **E2E** — `tests/e2e/j20-elegibilidade-respondi.spec.ts` (submissão real na URL da fonte → `ai_authorized_at` carimbado → o retorno pelo WhatsApp gera `job_queue` `inbound_turn`; CONTROLE: número sem Respondi no mesmo canal → evento `done` sem job) |
|
||||
| J20.7 | Segundo turno do Respondi (dias depois, conversa viva) | IA continua atendendo (keep-alive renova o carimbo) | **UNIT** — `gate.test.ts` "teste 6/7"; keep-alive em `inbound-turn.ts` |
|
||||
| J20.8 | Nova mensagem de campanha com identificador autorizado | IA pode assumir | **UNIT** — `campanha.test.ts` "teste 8" |
|
||||
| J20.9 | Nova mensagem genérica "oi" | IA NÃO responde | **UNIT** — `campanha.test.ts` "teste 9" + `gate.test.ts` |
|
||||
| J20.10 | Conversa marcada human_only (`force_human`) | IA nunca responde até reativação explícita | **UNIT** — `gate.test.ts` "teste 10", `drain.test.ts` "force_human" |
|
||||
| J20.11 | Follow-up em lead Respondi elegível | funciona | **CÓDIGO** — silence-sweep só barra quem o gate barra |
|
||||
| J20.12 | Follow-up em cliente atual (não autorizado, gate allowlist) | NÃO enrola | **CÓDIGO** — `silence-sweep.ts` `loadSilentContactIds` pula `gateAllowlist && !autorizado`; **E2E** — `tests/e2e/j20-elegibilidade-followup.spec.ts` (fluxo de silêncio publicado pela API + cron real: silencioso autorizado → nasce `followup_enrollments`; silencioso NÃO autorizado, mesmo canal → nenhum enrollment) |
|
||||
| J20.13 | Reinício do worker com backlog de eventos pending | zero disparos: cada evento cujo inbound já foi superado vira `done` sem job | **UNIT** — `drain.test.ts` "evento superado por inbound mais recente" |
|
||||
| J20.14 | Submissão antiga (fora do TTL) | NÃO reativa a IA sozinha | **UNIT** — `gate.test.ts` "submissão antiga (fora da janela)", `drain.test.ts` "autorização EXPIRADA" |
|
||||
| J20.15 | Org SEM versão de agente publicada (caminho legado `ai-response-worker`), gate allowlist, contato não autorizado | IA NÃO responde por este caminho tampouco | **UNIT** — `ai-response-worker-elegibilidade.test.ts` (skip `nao_elegivel_para_ia` antes de ler mensagem/agente; fail-closed em erro de leitura) |
|
||||
| J20.16 | Follow-up de TEXTO FIXO drenado inline (`enviarTextoFixoPendente`, sem worker), contato não autorizado | NÃO envia; job vira `done` | **UNIT** — `enviar-texto-fixo.test.ts` "conversa NÃO elegível" (+ fail-closed volta pra `pending`) |
|
||||
| J20.17 | Cliente antigo irritado (gate allowlist, não autorizado) → worker de sentimento dispara `low_sentiment` | `triggerHandoff` NÃO dispara: sem "um humano vai te atender", sem mexer no estado da conversa | **UNIT** — `handoff-orchestrator-elegibilidade.test.ts` (`bloqueioPorAllowlist` e `conversa_silenciada` barram; fail-closed em erro) |
|
||||
| J20.18 | Eu respondo o cliente à mão pelo meu WhatsApp numa conversa autorizada | IA para naquela conversa (silêncio durável + `last_handoff_reason`), SEM apagar `ai_authorized_at`; volta só por "devolver ao automático" | **UNIT** — `atendimento-manual.test.ts` (helper: idempotente, não toca autorização/force_human/status) + `waha-ingest-atendimento-manual.test.ts` (via `dispatchWahaEvent` real; eco do próprio envio NÃO pausa) + guarda de fonte no Zernio; **E2E** — `tests/e2e/j20-elegibilidade-atendimento-manual.spec.ts` (webhook `fromMe` genuíno → `bot_silenced_until='infinity'` + rastro; `ai_authorized_at` intacto; 2ª mensagem não re-carimba; tela mostra o selo; "devolver ao automático" solta a trava e a autorização continua) |
|
||||
| J20.19 | Worker parado acorda com backlog; dois inbound antigos com o MESMO `sent_at` | a "última inbound" é a mais RECENTE (por `created_at`), nunca a de maior uuid — o evento antigo é pulado | **INVARIANTE** — `tests/invariants/drain-recencia-inbound.test.ts` (Postgres real) + `drain.test.ts` guarda a cláusula `coalesce(sent_at, created_at)` |
|
||||
|
||||
**Sabotagem que confirma:** removendo o veto `sem_autorizacao` de
|
||||
`decidirElegibilidade`, `gate.test.ts` e `drain.test.ts` reprovam; restaurado,
|
||||
verde. Para J20.19: `order by id desc` sozinho elege a mensagem ANTIGA — o
|
||||
próprio invariante prova isso na asserção de sanidade.
|
||||
|
||||
**Cobertura de caminhos de envio (R1 — nenhum atalho):** o gate
|
||||
(`lib/ai/elegibilidade/gate.ts`, regra pura) é consultado por TODOS os produtores
|
||||
de resposta automática: drain + turno do agent-engine (`consulta-pg.ts`),
|
||||
`ai-response-worker` legado, `enviarTextoFixoPendente`, `runAgent` legado
|
||||
(`lib/ai/runtime/agent.ts`), `triggerHandoff` e o worker de sentimento
|
||||
(`consulta-supabase.ts`). `send_ai_message` é origem elegível (autoriza e então
|
||||
envia). Todos fail-closed: erro de leitura da elegibilidade → não responde.
|
||||
|
||||
**E2E (ambiente fresco estilo VPS):** J20.6, J20.12 e J20.18 têm spec própria
|
||||
(`tests/e2e/j20-elegibilidade-*.spec.ts`), rodando no job `e2e` do CI. Seed
|
||||
compartilhado `scripts/seed-e2e-elegibilidade.ts` (canal com `ai_gate='allowlist'`
|
||||
+ credencial validada + fonte de captação); helpers de SQL cru
|
||||
`scripts/e2e-elegibilidade-helpers.ts` (roda 1 tick do `drainTick` real — a suíte
|
||||
não sobe worker —, lê `job_queue`/`event_log`/`followup_enrollments`, semeia os
|
||||
dois estados de partida do gate). A submissão do Respondi e as mensagens do WAHA
|
||||
entram pelas rotas REAIS do app (`/api/v1/webhooks/in/:token`,
|
||||
`/api/v1/webhooks/waha/:token`). O agente publicado é SETUP via helper porque
|
||||
`POST /api/v1/ai/agents` exige role `admin`/MFA e o agente não é o que está sob
|
||||
teste.
|
||||
|
||||
**A tela do knob `ai_gate` e do editor de `campanhas_whatsapp` ainda não existe**
|
||||
— hoje se liga por script/SQL (`scripts/ativar-gate-elegibilidade-ia.ts`), como o
|
||||
`roteamento_de_formulario`. É a dívida declarada desta entrega.
|
||||
|
||||
**Dívida no `campanhas_whatsapp`:** o campo `agent_id` de uma campanha é aceito
|
||||
no schema mas **NÃO é roteado** — o match só torna o contato elegível
|
||||
(`ai_authorized_reason = campanha:<id>`); quem assume o turno é sempre o
|
||||
roteador / agente publicado da sessão. Encaminhar por campanha exige levar
|
||||
`agent_id` no payload de `ai_agent.dispatch_requested` e o `resolve-turn-agent`
|
||||
respeitá-lo. `label`/`segmento` são display-only (dependem da tela).
|
||||
|
||||
---
|
||||
|
||||
## J7 — Exploração completa `[P2]`
|
||||
|
||||
Andar por TODAS as rotas navegáveis logado como admin e como agent: settings, contacts,
|
||||
|
||||
@@ -136,6 +136,7 @@ import {
|
||||
import { camadaLigada, lerCamadasDaOrg } from '../guardrails/camadas-da-org';
|
||||
import { fusoDaOrganizacao } from './fuso-da-org';
|
||||
import { renderAgora } from '@/lib/tempo/agora';
|
||||
import { decidirElegibilidadeDaConversa } from '@/lib/ai/elegibilidade/consulta-pg';
|
||||
|
||||
/**
|
||||
* Superfície ESTÁTICA das tools do agente (description + inputSchema) — parte do
|
||||
@@ -744,8 +745,18 @@ export interface InboundTurnKnobs {
|
||||
* Ausente = usa o defaultModel da org (mesma convenção de stageClassifier/jailbreak).
|
||||
*/
|
||||
followupAi?: { model?: string };
|
||||
/**
|
||||
* Janela de validade da autorização de IA de um contato (gate opt-in
|
||||
* `channel_sessions.metadata.ai_gate = 'allowlist'`). Só consultada quando o
|
||||
* canal tem o gate ligado. Ausente nos testes que não o exercitam — o default
|
||||
* de 21 dias é aplicado.
|
||||
*/
|
||||
allowlistTtlMs?: number;
|
||||
}
|
||||
|
||||
/** Default de `allowlistTtlMs` (21 dias) para testes que omitem o knob. */
|
||||
export const ALLOWLIST_TTL_MS_PADRAO = 21 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export interface InboundTurnDeps {
|
||||
crmCfg: CrmEdgeConfig;
|
||||
llmCfg: LlmEdgeConfig;
|
||||
@@ -1234,6 +1245,50 @@ async function executarTurnoDoAgente(
|
||||
return;
|
||||
}
|
||||
|
||||
// GATE DE ELEGIBILIDADE (opt-in por canal — `metadata.ai_gate = 'allowlist'`).
|
||||
// Segunda checagem, defesa em profundidade: o drain já barra antes de
|
||||
// enfileirar, mas um job pode ter sido enfileirado quando a conversa ainda
|
||||
// estava autorizada e um humano assumiu no meio-tempo, ou o gate do canal
|
||||
// mudou. Canal 'open' (default) → `permite:true`, nada muda. NO-OP no início do
|
||||
// turno, antes de qualquer chamada de modelo — mesmo lugar e mesmo custo do
|
||||
// veto de handoff acima.
|
||||
try {
|
||||
const elegib = await decidirElegibilidadeDaConversa(pool, {
|
||||
organizationId: tenantId,
|
||||
conversationId: input.conversationId,
|
||||
agora: clock(),
|
||||
ttlMs: deps.knobs.allowlistTtlMs ?? ALLOWLIST_TTL_MS_PADRAO,
|
||||
});
|
||||
if (elegib !== null && !elegib.permite) {
|
||||
runLog.info('turno pulado — conversa não elegível para IA', {
|
||||
kind: job.kind,
|
||||
motivo: elegib.motivo,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// KEEP-ALIVE: enquanto a conversa autorizada está viva, renova o carimbo —
|
||||
// assim uma negociação de semanas não expira pela janela de validade, mas um
|
||||
// contato que veio de uma submissão e sumiu volta a NÃO ser elegível depois
|
||||
// da janela. Só no modo 'allowlist' (motivo 'autorizado'); fire-and-forget.
|
||||
if (elegib !== null && elegib.motivo === 'autorizado' && job.kind === 'inbound_turn') {
|
||||
pool
|
||||
.query(
|
||||
`update contacts set ai_authorized_at = now()
|
||||
where organization_id = $1 and id = $2 and ai_authorized_at is not null`,
|
||||
[tenantId, leadId],
|
||||
)
|
||||
.catch((err: unknown) => {
|
||||
runLog.warn('keep-alive da autorização de IA falhou', {
|
||||
error: (err instanceof Error ? err.message : String(err)).slice(0, 120),
|
||||
});
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
runLog.warn('checagem de elegibilidade falhou no turno — seguindo', {
|
||||
error: (err instanceof Error ? err.message : String(err)).slice(0, 160),
|
||||
});
|
||||
}
|
||||
|
||||
// JANELA ANTI-BAN (7h–22h por padrão, fuso do tenant): fora dela o turno é
|
||||
// ADIADO, não gasto.
|
||||
//
|
||||
|
||||
@@ -49,6 +49,12 @@
|
||||
* shape quebrado) NUNCA derruba o turno — cai no `loadPublishedAgentConfig`
|
||||
* de hoje (sem router) com outcome 'classifier_failed' + log.warn. Um lead
|
||||
* real está esperando resposta; o router é estritamente aditivo.
|
||||
*
|
||||
* ⚠️ "silêncio não é desfecho possível" (regra 5) vale para os turnos que
|
||||
* CHEGAM aqui. O gate de elegibilidade (migration 0203, canal com
|
||||
* `metadata.ai_gate = 'allowlist'`) barra ANTES — no drain e no início do turno,
|
||||
* via `decidirElegibilidadeDaConversa` — quando o contato não veio de uma origem
|
||||
* elegível. Ali o silêncio É o desfecho, e de propósito.
|
||||
*/
|
||||
import type pg from 'pg';
|
||||
|
||||
|
||||
@@ -134,3 +134,123 @@ it('sem agente MAS com roteador que resolve alguém: turno segue (caminho genér
|
||||
);
|
||||
expect(calls.some((s) => s.includes('job_queue'))).toBe(true);
|
||||
});
|
||||
|
||||
/**
|
||||
* Anti-backlog + gate de elegibilidade (migration 0203).
|
||||
*
|
||||
* `poolElegibilidade` estende o pool falso com as duas consultas novas: a de
|
||||
* supersessão (última inbound da conversa) e a de elegibilidade (gate do canal
|
||||
* + travas do contato).
|
||||
*/
|
||||
function poolElegibilidade(
|
||||
calls: string[],
|
||||
opts: {
|
||||
ultimaInboundId?: string;
|
||||
aiGate?: string | null;
|
||||
aiAuthorizedAt?: string | null;
|
||||
forceHuman?: boolean;
|
||||
assigneeKind?: string | null;
|
||||
} = {},
|
||||
) {
|
||||
const inboundId = '44444444-4444-4444-8444-444444444444';
|
||||
const query = vi.fn().mockImplementation((sql: string) => {
|
||||
calls.push(sql);
|
||||
if (sql.includes('returning e.id')) return { rows: [{ ...event, created_at: new Date().toISOString() }] };
|
||||
if (sql.includes('ai_dispatch_mode')) return { rows: [{ mode: null }] };
|
||||
if (sql.includes('is_group')) return { rows: [{ is_group: false }] };
|
||||
if (sql.includes('tem_agente')) return { rows: [{ tem_agente: true, tem_roteador: false }] };
|
||||
if (sql.includes("direction = 'inbound'")) {
|
||||
return { rows: [{ id: opts.ultimaInboundId ?? inboundId }] };
|
||||
}
|
||||
if (sql.includes("->>'ai_gate'")) {
|
||||
return {
|
||||
rows: [
|
||||
{
|
||||
ai_gate: opts.aiGate ?? null,
|
||||
force_human: opts.forceHuman ?? false,
|
||||
assignee_kind: opts.assigneeKind ?? 'ai',
|
||||
bot_silenced_until: null,
|
||||
ai_authorized_at: opts.aiAuthorizedAt ?? null,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (sql.includes('media_derived_status')) return { rows: [{ type: 'text', media_derived_status: null }] };
|
||||
return { rows: [] };
|
||||
});
|
||||
return { query } as unknown as pg.Pool;
|
||||
}
|
||||
|
||||
it('evento superado por inbound mais recente: turno pulado, sem job, sem gasto', async () => {
|
||||
const calls: string[] = [];
|
||||
await drainTick(poolElegibilidade(calls, { ultimaInboundId: 'outra-mensagem-mais-nova' }), knobs, log);
|
||||
expect(calls.some((s) => s.includes("direction = 'inbound'"))).toBe(true);
|
||||
expect(calls.some((s) => s.includes('job_queue'))).toBe(false);
|
||||
expect(calls.some((s) => s.includes("status = 'done'"))).toBe(true);
|
||||
});
|
||||
|
||||
it("gate 'allowlist' + contato NÃO autorizado: turno pulado, sem job, sem gasto", async () => {
|
||||
const calls: string[] = [];
|
||||
await drainTick(poolElegibilidade(calls, { aiGate: 'allowlist', aiAuthorizedAt: null }), knobs, log);
|
||||
expect(calls.some((s) => s.includes("->>'ai_gate'"))).toBe(true);
|
||||
expect(calls.some((s) => s.includes('job_queue'))).toBe(false);
|
||||
expect(calls.some((s) => s.includes("status = 'done'"))).toBe(true);
|
||||
});
|
||||
|
||||
it("gate 'allowlist' + contato autorizado agora: turno segue", async () => {
|
||||
const calls: string[] = [];
|
||||
await drainTick(
|
||||
poolElegibilidade(calls, { aiGate: 'allowlist', aiAuthorizedAt: new Date().toISOString() }),
|
||||
{ ...knobs, allowlistTtlMs: 21 * 24 * 60 * 60 * 1000 },
|
||||
log,
|
||||
);
|
||||
expect(calls.some((s) => s.includes('job_queue'))).toBe(true);
|
||||
});
|
||||
|
||||
it("gate 'allowlist' + autorização EXPIRADA (fora do TTL): turno pulado", async () => {
|
||||
const calls: string[] = [];
|
||||
const antiga = new Date(Date.now() - 40 * 24 * 60 * 60 * 1000).toISOString();
|
||||
await drainTick(
|
||||
poolElegibilidade(calls, { aiGate: 'allowlist', aiAuthorizedAt: antiga }),
|
||||
{ ...knobs, allowlistTtlMs: 21 * 24 * 60 * 60 * 1000 },
|
||||
log,
|
||||
);
|
||||
expect(calls.some((s) => s.includes('job_queue'))).toBe(false);
|
||||
expect(calls.some((s) => s.includes("status = 'done'"))).toBe(true);
|
||||
});
|
||||
|
||||
it("gate 'open' (default): contato sem autorização NÃO é barrado — comportamento de hoje", async () => {
|
||||
const calls: string[] = [];
|
||||
await drainTick(poolElegibilidade(calls, { aiGate: null, aiAuthorizedAt: null }), knobs, log);
|
||||
expect(calls.some((s) => s.includes('job_queue'))).toBe(true);
|
||||
});
|
||||
|
||||
it("gate 'allowlist' + force_human: turno pulado mesmo com autorização", async () => {
|
||||
const calls: string[] = [];
|
||||
await drainTick(
|
||||
poolElegibilidade(calls, {
|
||||
aiGate: 'allowlist',
|
||||
aiAuthorizedAt: new Date().toISOString(),
|
||||
forceHuman: true,
|
||||
}),
|
||||
knobs,
|
||||
log,
|
||||
);
|
||||
expect(calls.some((s) => s.includes('job_queue'))).toBe(false);
|
||||
});
|
||||
|
||||
/**
|
||||
* R7 — a consulta da "última inbound" (anti-backlog) desempata por recência
|
||||
* REAL, nunca por `id` (uuid aleatório). `order by sent_at desc nulls last, id
|
||||
* desc` elegia a mensagem ANTIGA por sorteio quando dois inbound tinham o mesmo
|
||||
* `sent_at`. A cerca guarda a cláusula seguindo o padrão do repo (migration
|
||||
* 0027): `coalesce(sent_at, created_at)`.
|
||||
*/
|
||||
it("anti-backlog: ordena a última inbound por coalesce(sent_at, created_at), não por id sozinho", async () => {
|
||||
const calls: string[] = [];
|
||||
await drainTick(poolElegibilidade(calls), knobs, log);
|
||||
const consultaUltima = calls.find((s) => s.includes("direction = 'inbound'"));
|
||||
expect(consultaUltima).toBeDefined();
|
||||
expect(consultaUltima).toContain('coalesce(sent_at, created_at) desc');
|
||||
expect(consultaUltima).not.toContain('nulls last');
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ import type pg from 'pg';
|
||||
import type { Logger } from '../../obs/logger';
|
||||
import { enqueueJob } from '../../queue/queue';
|
||||
import { TIPOS_DERIVAVEIS, DERIVACAO_TERMINADA } from '@/lib/messaging/media/derivable';
|
||||
import { decidirElegibilidadeDaConversa } from '@/lib/ai/elegibilidade/consulta-pg';
|
||||
|
||||
const DRAIN_CONSUMER = 'agent-engine';
|
||||
|
||||
@@ -46,8 +47,17 @@ export interface DrainKnobs {
|
||||
debounceMs: number;
|
||||
/** Evento 'processing' órfão volta a 'pending' após isto. */
|
||||
reapTimeoutMs: number;
|
||||
/**
|
||||
* Janela de validade da autorização de IA de um contato (gate 'allowlist').
|
||||
* Só consultada em canal com `metadata.ai_gate = 'allowlist'`. Ausente nos
|
||||
* testes que não exercitam o gate — o default de 21 dias em ms é aplicado.
|
||||
*/
|
||||
allowlistTtlMs?: number;
|
||||
}
|
||||
|
||||
/** Default de `allowlistTtlMs` (21 dias) para testes que omitem o knob. */
|
||||
const ALLOWLIST_TTL_MS_PADRAO = 21 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/** Um tick do drain: claima um lote de eventos e os transforma em jobs. */
|
||||
export async function drainTick(
|
||||
pool: pg.Pool,
|
||||
@@ -241,6 +251,73 @@ async function processEvent(
|
||||
return 'processado';
|
||||
}
|
||||
|
||||
// ANTI-BACKLOG (toda instalação, sem knob): a mensagem que disparou este
|
||||
// evento ainda é a última inbound da conversa? Se já veio inbound mais nova,
|
||||
// ESTE evento está superado — a mensagem nova tem o próprio evento, e o turno
|
||||
// dela lê o histórico inteiro (esta mensagem inclusa). Sem isto, um worker que
|
||||
// ficou parado (deploy, OOM na VPS) acorda e drena o backlog em ordem de
|
||||
// `created_at`, disparando um turno para CADA mensagem antiga — a IA
|
||||
// respondendo conversa de dias atrás. Vira done, sem job, sem gasto.
|
||||
//
|
||||
// R7: o desempate. `order by sent_at desc, id desc` cai no `id` — uuid
|
||||
// aleatório, não cronológico — sempre que dois inbound compartilham `sent_at`
|
||||
// (relógio do provider repetido, ou duas mensagens na mesma janela sem
|
||||
// timestamp). "A última" saía por sorteio e podia eleger a ANTIGA, disparando
|
||||
// o turno dela. `coalesce(sent_at, created_at)` (defensivo — `sent_at` é
|
||||
// `not null default now()` hoje, mas o padrão do repo, ver migration 0027, não
|
||||
// confia nisso) com desempate por `created_at` (ordem de INGESTÃO, uma
|
||||
// mensagem por webhook) dá recência determinística.
|
||||
const { rows: ultimaInbound } = await pool.query<{ id: string }>(
|
||||
`select id from messages
|
||||
where organization_id = $1 and conversation_id = $2 and direction = 'inbound'
|
||||
order by coalesce(sent_at, created_at) desc, created_at desc, id desc
|
||||
limit 1`,
|
||||
[event.organization_id, p.conversation_id],
|
||||
);
|
||||
if (ultimaInbound[0] !== undefined && ultimaInbound[0].id !== p.inbound_message_id) {
|
||||
log.info('drain: evento superado por inbound mais recente — turno pulado (sem gasto)', {
|
||||
event_id: event.id,
|
||||
inbound_message_id: p.inbound_message_id,
|
||||
ultima_inbound_id: ultimaInbound[0].id,
|
||||
});
|
||||
return 'processado';
|
||||
}
|
||||
|
||||
// GATE DE ELEGIBILIDADE (opt-in por canal — `metadata.ai_gate = 'allowlist'`).
|
||||
// Num canal 'open' (o default), `decidirElegibilidade` devolve `permite:true`
|
||||
// com motivo 'gate_aberto' e nada muda. Num canal 'allowlist', a IA só assume
|
||||
// se o CONTATO estiver autorizado por uma origem elegível (Respondi, campanha,
|
||||
// automação, retomada manual) e dentro da janela. Bloqueio por allowlist =
|
||||
// done, sem job, sem gasto — a conversa fica para atendimento humano.
|
||||
//
|
||||
// `force_human` / silêncio / dono humano bloqueiam em QUALQUER modo: o turno já
|
||||
// os respeitava (`isLeadInHandoff`), aqui a decisão só se antecipa para não
|
||||
// enfileirar. O turno revalida (defesa em profundidade).
|
||||
try {
|
||||
const elegib = await decidirElegibilidadeDaConversa(pool, {
|
||||
organizationId: event.organization_id,
|
||||
conversationId: p.conversation_id,
|
||||
agora: new Date(),
|
||||
ttlMs: knobs.allowlistTtlMs ?? ALLOWLIST_TTL_MS_PADRAO,
|
||||
});
|
||||
if (elegib !== null && !elegib.permite) {
|
||||
log.info('drain: conversa não elegível para IA — turno pulado (sem gasto)', {
|
||||
event_id: event.id,
|
||||
conversation_id: p.conversation_id,
|
||||
motivo: elegib.motivo,
|
||||
});
|
||||
return 'processado';
|
||||
}
|
||||
} catch (err) {
|
||||
// Falha da consulta de elegibilidade NÃO derruba o drain e NÃO bloqueia o
|
||||
// turno: um lead real pode estar esperando. Degrada para o fluxo antigo
|
||||
// (enfileira) — o turno tem a segunda checagem.
|
||||
log.warn('drain: checagem de elegibilidade falhou — seguindo para o turno', {
|
||||
event_id: event.id,
|
||||
error: (err instanceof Error ? err.message : String(err)).slice(0, 160),
|
||||
});
|
||||
}
|
||||
|
||||
// Mídia ainda virando texto: ESPERAR. Sem isto o turno era despachado no mesmo
|
||||
// instante em que a mensagem chegava, enquanto o áudio ainda estava sendo
|
||||
// baixado e transcrito — e o cliente recebia "recebi seu áudio, mas não
|
||||
|
||||
@@ -198,6 +198,12 @@ const envSchema = z.object({
|
||||
FLYWHEEL_BATCH_LIMIT: z.coerce.number().int().positive().default(10),
|
||||
// Contenção de egress — hosts EXTRA além do Supabase/WAHA (CSV). Fail-closed.
|
||||
EGRESS_EXTRA_ALLOWED_HOSTS: z.string().optional(),
|
||||
// Elegibilidade da IA (gate opt-in `channel_sessions.metadata.ai_gate=allowlist`):
|
||||
// janela de validade da autorização de um contato. Fora dela, submissão antiga
|
||||
// não reativa a IA; o turno autorizado renova o carimbo enquanto a conversa
|
||||
// está viva. Só tem efeito nos canais com o gate ligado — canal 'open' (o
|
||||
// default) nunca consulta autorização.
|
||||
AI_ALLOWLIST_TTL_DAYS: z.coerce.number().int().positive().default(21),
|
||||
});
|
||||
|
||||
export type Env = z.infer<typeof envSchema>;
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* ESCREVER a autorização de IA de um contato — num lugar só.
|
||||
*
|
||||
* As quatro origens elegíveis (webhook do Respondi, match de campanha na
|
||||
* ingestão, ação de automação `send_ai_message`, retomada manual pela tela)
|
||||
* chamam `autorizarContatoParaIA`. A `reason` é o rastro: quem lê
|
||||
* `contacts.ai_authorized_reason` sabe por que a IA pôde assumir.
|
||||
*
|
||||
* `revogarAutorizacaoDeIA` é o oposto — usado quando um humano assume o
|
||||
* atendimento (a retomada para a IA re-autoriza; a passagem para humano revoga).
|
||||
* `force_human` continua sendo a trava dura e irrevogável pelo agente; isto aqui
|
||||
* é a camada de elegibilidade, não a de handoff.
|
||||
*/
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
export type MotivoDeAutorizacao =
|
||||
| `respondi:${string}`
|
||||
| `campanha:${string}`
|
||||
| `automacao:${string}`
|
||||
| "retomada_manual";
|
||||
|
||||
/**
|
||||
* Carimba `contacts.ai_authorized_at = now()` + `ai_authorized_reason`.
|
||||
* Best-effort: falha vira log, nunca derruba o fluxo que chamou (webhook,
|
||||
* ingestão, automação). Um contato que não ficou autorizado por erro de banco
|
||||
* simplesmente segue com atendimento humano — o lado seguro.
|
||||
*
|
||||
* `apenasSeNaoAutorizado`: quando `true`, não re-carimba um contato que já tem
|
||||
* `ai_authorized_at` (evita que match de campanha em toda mensagem fique
|
||||
* reescrevendo a origem de um lead que veio do Respondi).
|
||||
*/
|
||||
export async function autorizarContatoParaIA(
|
||||
supabase: SupabaseClient,
|
||||
input: {
|
||||
organizationId: string;
|
||||
contactId: string;
|
||||
reason: MotivoDeAutorizacao;
|
||||
apenasSeNaoAutorizado?: boolean;
|
||||
},
|
||||
): Promise<{ ok: boolean; autorizou: boolean }> {
|
||||
try {
|
||||
let q = supabase
|
||||
.from("contacts")
|
||||
.update({
|
||||
ai_authorized_at: new Date().toISOString(),
|
||||
ai_authorized_reason: input.reason,
|
||||
})
|
||||
.eq("organization_id", input.organizationId)
|
||||
.eq("id", input.contactId);
|
||||
|
||||
if (input.apenasSeNaoAutorizado) {
|
||||
q = q.is("ai_authorized_at", null);
|
||||
}
|
||||
|
||||
const { data, error } = await q.select("id").maybeSingle();
|
||||
if (error) {
|
||||
logger.warn("[elegibilidade] autorização de IA não gravada", {
|
||||
organization_id: input.organizationId,
|
||||
contact_id: input.contactId,
|
||||
reason: input.reason,
|
||||
detail: error.message.slice(0, 160),
|
||||
});
|
||||
return { ok: false, autorizou: false };
|
||||
}
|
||||
return { ok: true, autorizou: data != null };
|
||||
} catch (err) {
|
||||
logger.warn("[elegibilidade] autorização de IA falhou", {
|
||||
organization_id: input.organizationId,
|
||||
contact_id: input.contactId,
|
||||
reason: input.reason,
|
||||
detail: err instanceof Error ? err.message.slice(0, 160) : "desconhecido",
|
||||
});
|
||||
return { ok: false, autorizou: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Limpa a autorização — a IA volta a NÃO responder (no gate 'allowlist') até
|
||||
* alguém re-autorizar. Best-effort pela mesma razão.
|
||||
*/
|
||||
export async function revogarAutorizacaoDeIA(
|
||||
supabase: SupabaseClient,
|
||||
input: { organizationId: string; contactId: string },
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from("contacts")
|
||||
.update({ ai_authorized_at: null, ai_authorized_reason: null })
|
||||
.eq("organization_id", input.organizationId)
|
||||
.eq("id", input.contactId);
|
||||
if (error) {
|
||||
logger.warn("[elegibilidade] revogação de autorização de IA não gravada", {
|
||||
organization_id: input.organizationId,
|
||||
contact_id: input.contactId,
|
||||
detail: error.message.slice(0, 160),
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn("[elegibilidade] revogação de autorização de IA falhou", {
|
||||
organization_id: input.organizationId,
|
||||
contact_id: input.contactId,
|
||||
detail: err instanceof Error ? err.message.slice(0, 160) : "desconhecido",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
casarCampanha,
|
||||
lerCampanhas,
|
||||
normalizarParaMatch,
|
||||
parseCampanhas,
|
||||
type CampanhaWhatsapp,
|
||||
} from "./campanha";
|
||||
|
||||
const SESSAO = "33333333-3333-4333-8333-333333333333";
|
||||
const OUTRA_SESSAO = "99999999-9999-4999-8999-999999999999";
|
||||
|
||||
const incorporadoras: CampanhaWhatsapp = {
|
||||
id: "incorporadoras-meta",
|
||||
match: { tipo: "contains", valor: "marketing para incorporadoras" },
|
||||
segmento: "imobiliario",
|
||||
};
|
||||
const videos: CampanhaWhatsapp = {
|
||||
id: "videos-google",
|
||||
match: { tipo: "starts_with", valor: "Quero orçamento para vídeos" },
|
||||
};
|
||||
|
||||
describe("normalizarParaMatch", () => {
|
||||
it("minúsculas, sem acento, espaços colapsados", () => {
|
||||
expect(normalizarParaMatch(" Quero ORÇAMENTO para Vídeos ")).toBe("quero orcamento para videos");
|
||||
});
|
||||
});
|
||||
|
||||
describe("casarCampanha", () => {
|
||||
it("teste 8: mensagem de campanha com identificador autorizado → casa", () => {
|
||||
const c = casarCampanha(
|
||||
"Olá! Quero saber mais sobre marketing para incorporadoras",
|
||||
[incorporadoras, videos],
|
||||
SESSAO,
|
||||
);
|
||||
expect(c?.id).toBe("incorporadoras-meta");
|
||||
});
|
||||
|
||||
it("acento/caixa não impedem o match", () => {
|
||||
expect(
|
||||
casarCampanha("quero saber mais sobre MARKETING PARA INCORPORADORAS", [incorporadoras], SESSAO)?.id,
|
||||
).toBe("incorporadoras-meta");
|
||||
});
|
||||
|
||||
it("teste 9: 'Boa noite' não casa nenhuma campanha", () => {
|
||||
expect(casarCampanha("Boa noite", [incorporadoras, videos], SESSAO)).toBeNull();
|
||||
});
|
||||
|
||||
it("mensagem vazia/null → null", () => {
|
||||
expect(casarCampanha(null, [incorporadoras], SESSAO)).toBeNull();
|
||||
expect(casarCampanha(" ", [incorporadoras], SESSAO)).toBeNull();
|
||||
});
|
||||
|
||||
it("starts_with exige o prefixo no começo", () => {
|
||||
expect(casarCampanha("Quero orçamento para vídeos institucionais", [videos], SESSAO)?.id).toBe(
|
||||
"videos-google",
|
||||
);
|
||||
expect(casarCampanha("Antes disso, quero orçamento para vídeos", [videos], SESSAO)).toBeNull();
|
||||
});
|
||||
|
||||
it("campanha presa a outro canal não casa neste", () => {
|
||||
const presa: CampanhaWhatsapp = { ...incorporadoras, channel_session_id: OUTRA_SESSAO };
|
||||
expect(casarCampanha("marketing para incorporadoras", [presa], SESSAO)).toBeNull();
|
||||
expect(casarCampanha("marketing para incorporadoras", [presa], OUTRA_SESSAO)?.id).toBe(
|
||||
"incorporadoras-meta",
|
||||
);
|
||||
});
|
||||
|
||||
it("primeira campanha que casa vence", () => {
|
||||
const a: CampanhaWhatsapp = { id: "a", match: { tipo: "contains", valor: "orçamento" } };
|
||||
const b: CampanhaWhatsapp = { id: "b", match: { tipo: "contains", valor: "orçamento para vídeos" } };
|
||||
expect(casarCampanha("quero orçamento para vídeos", [a, b], SESSAO)?.id).toBe("a");
|
||||
});
|
||||
});
|
||||
|
||||
describe("lerCampanhas / schema", () => {
|
||||
it("lê a lista de organizations.settings", () => {
|
||||
const campanhas = lerCampanhas({ campanhas_whatsapp: [incorporadoras] });
|
||||
expect(campanhas).toHaveLength(1);
|
||||
expect(campanhas[0]?.id).toBe("incorporadoras-meta");
|
||||
});
|
||||
|
||||
it("settings ausente/malformado → lista vazia, nunca lança", () => {
|
||||
expect(lerCampanhas(null)).toEqual([]);
|
||||
expect(lerCampanhas({})).toEqual([]);
|
||||
expect(lerCampanhas({ campanhas_whatsapp: "lixo" })).toEqual([]);
|
||||
});
|
||||
|
||||
it("item malformado é descartado sem derrubar os válidos", () => {
|
||||
const r = parseCampanhas([{ id: "x" }, incorporadoras, { id: "y", match: { tipo: "contains", valor: "ab" } }]);
|
||||
expect(r.map((c) => c.id)).toEqual(["incorporadoras-meta"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* CONFIG CENTRAL DE CAMPANHA → elegibilidade da IA.
|
||||
*
|
||||
* O caso 2 da elegibilidade: campanhas de Meta/Google que levam direto para o
|
||||
* WhatsApp. O lead chega com uma mensagem identificadora ("Quero saber mais
|
||||
* sobre marketing para incorporadoras"). A IA só assume se a mensagem
|
||||
* corresponder a uma campanha explicitamente registrada.
|
||||
*
|
||||
* ─── Nada de frase hardcoded no código ──────────────────────────────────────
|
||||
*
|
||||
* A lista vive em `organizations.settings.campanhas_whatsapp` (jsonb) — o dono
|
||||
* registra origem → condição → agente → segmento pela configuração, não por
|
||||
* deploy. Este arquivo só valida o formato e casa uma mensagem contra a lista.
|
||||
*
|
||||
* ─── O match é conservador ──────────────────────────────────────────────────
|
||||
*
|
||||
* `contains` (substring, case/acento-insensível) e `starts_with`. Sem regex de
|
||||
* usuário: uma regex mal-escrita na config viraria ReDoS no caminho de ingestão
|
||||
* de toda mensagem. Se um dia precisar, entra como tipo novo com timeout.
|
||||
* A condição é comparada com a mensagem inteira normalizada; o dono escolhe uma
|
||||
* frase específica o suficiente para não colidir com conversa comum.
|
||||
*/
|
||||
import { z } from "zod";
|
||||
|
||||
export const CAMPANHA_MATCH_TIPOS = ["contains", "starts_with"] as const;
|
||||
|
||||
export const campanhaWhatsappSchema = z.object({
|
||||
/** id estável da campanha — entra em `ai_authorized_reason` como `campanha:<id>`. */
|
||||
id: z.string().min(1).max(64),
|
||||
/** rótulo legível para a tela (a tela ainda não existe — hoje só documenta a config). */
|
||||
label: z.string().min(1).max(120).optional(),
|
||||
match: z.object({
|
||||
tipo: z.enum(CAMPANHA_MATCH_TIPOS),
|
||||
/** a frase/prefixo identificador da campanha. */
|
||||
valor: z.string().min(3).max(400),
|
||||
}),
|
||||
/**
|
||||
* RESERVADO — ainda NÃO roteado. O match de campanha só torna o contato
|
||||
* elegível (`ai_authorized_reason = campanha:<id>`); quem assume o turno é
|
||||
* sempre o roteador / agente publicado da sessão (`resolve-turn-agent.ts`).
|
||||
* Encaminhar por campanha exige levar o `agent_id` no payload do
|
||||
* `ai_agent.dispatch_requested` e o `resolve-turn-agent` respeitá-lo — não
|
||||
* feito nesta entrega. Aceito no schema para a config não quebrar quando a
|
||||
* rota existir. Ver J20 (dívida declarada) no user-journey-map.
|
||||
*/
|
||||
agent_id: z.string().uuid().optional(),
|
||||
/** segmento/nicho, só para contexto e exibição (mesma pendência de tela do `label`). */
|
||||
segmento: z.string().min(1).max(64).optional(),
|
||||
/** limita a campanha a um canal específico; ausente = qualquer canal da org. */
|
||||
channel_session_id: z.string().uuid().optional(),
|
||||
});
|
||||
export type CampanhaWhatsapp = z.infer<typeof campanhaWhatsappSchema>;
|
||||
|
||||
/**
|
||||
* Lê a lista tolerando item malformado: um objeto inválido é DESCARTADO, não
|
||||
* derruba os demais (`z.array().catch([])` zeraria tudo por causa de um só). A
|
||||
* lista inteira só vira `[]` quando o valor cru nem é um array.
|
||||
*/
|
||||
export function parseCampanhas(raw: unknown): CampanhaWhatsapp[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const out: CampanhaWhatsapp[] = [];
|
||||
for (const item of raw.slice(0, 100)) {
|
||||
const parsed = campanhaWhatsappSchema.safeParse(item);
|
||||
if (parsed.success) out.push(parsed.data);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normaliza para comparar: minúsculas, sem acento, espaços colapsados. A mesma
|
||||
* normalização dos dois lados (mensagem e `match.valor`).
|
||||
*/
|
||||
export function normalizarParaMatch(texto: string): string {
|
||||
return texto
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* A primeira campanha cuja condição casa a mensagem, ou `null`. `channelSessionId`
|
||||
* filtra campanhas presas a outro canal.
|
||||
*/
|
||||
export function casarCampanha(
|
||||
texto: string | null,
|
||||
campanhas: CampanhaWhatsapp[],
|
||||
channelSessionId: string,
|
||||
): CampanhaWhatsapp | null {
|
||||
if (!texto || texto.trim() === "") return null;
|
||||
const alvo = normalizarParaMatch(texto);
|
||||
if (alvo === "") return null;
|
||||
|
||||
for (const c of campanhas) {
|
||||
if (c.channel_session_id !== undefined && c.channel_session_id !== channelSessionId) continue;
|
||||
const valor = normalizarParaMatch(c.match.valor);
|
||||
if (valor === "") continue;
|
||||
const casa = c.match.tipo === "starts_with" ? alvo.startsWith(valor) : alvo.includes(valor);
|
||||
if (casa) return c;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Lê e valida a lista do `organizations.settings` (jsonb cru). */
|
||||
export function lerCampanhas(settings: unknown): CampanhaWhatsapp[] {
|
||||
const raw =
|
||||
settings !== null && typeof settings === "object"
|
||||
? (settings as Record<string, unknown>).campanhas_whatsapp
|
||||
: undefined;
|
||||
return parseCampanhas(raw);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Ler o estado de elegibilidade de uma conversa via pool `pg` (o transporte do
|
||||
* agent-engine). Uma query, três tabelas: o modo do gate do canal, as travas do
|
||||
* contato, o silêncio da conversa.
|
||||
*
|
||||
* O drain (decide ENFILEIRAR) e o turno (decide RODAR) chamam isto e passam o
|
||||
* resultado para `decidirElegibilidade` — a MESMA regra pura.
|
||||
*/
|
||||
import type pg from "pg";
|
||||
|
||||
import {
|
||||
decidirElegibilidade,
|
||||
montarEstadoDeElegibilidade,
|
||||
type DecisaoDeElegibilidade,
|
||||
} from "./gate";
|
||||
|
||||
interface LinhaDeElegibilidade {
|
||||
ai_gate: string | null;
|
||||
force_human: boolean | null;
|
||||
assignee_kind: string | null;
|
||||
bot_silenced_until: Date | string | null;
|
||||
ai_authorized_at: Date | string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Roda a query e a regra. `null` = conversa não encontrada (deixe o chamador
|
||||
* decidir; o drain trata como "sem gate", segue o fluxo antigo).
|
||||
*/
|
||||
export async function decidirElegibilidadeDaConversa(
|
||||
pool: pg.Pool,
|
||||
input: { organizationId: string; conversationId: string; agora: Date; ttlMs: number },
|
||||
): Promise<DecisaoDeElegibilidade | null> {
|
||||
const { rows } = await pool.query<LinhaDeElegibilidade>(
|
||||
`select
|
||||
cs.metadata->>'ai_gate' as ai_gate,
|
||||
ct.force_human as force_human,
|
||||
cv.assignee_kind as assignee_kind,
|
||||
cv.bot_silenced_until as bot_silenced_until,
|
||||
ct.ai_authorized_at as ai_authorized_at
|
||||
from conversations cv
|
||||
join contacts ct
|
||||
on ct.id = cv.contact_id and ct.organization_id = cv.organization_id
|
||||
join channel_sessions cs
|
||||
on cs.id = cv.channel_session_id and cs.organization_id = cv.organization_id
|
||||
where cv.organization_id = $1 and cv.id = $2`,
|
||||
[input.organizationId, input.conversationId],
|
||||
);
|
||||
const r = rows[0];
|
||||
if (r === undefined) return null;
|
||||
|
||||
return decidirElegibilidade(
|
||||
montarEstadoDeElegibilidade({
|
||||
aiGate: r.ai_gate,
|
||||
forceHuman: r.force_human,
|
||||
assigneeKind: r.assignee_kind,
|
||||
botSilencedUntil: r.bot_silenced_until,
|
||||
aiAuthorizedAt: r.ai_authorized_at,
|
||||
agora: input.agora,
|
||||
ttlMs: input.ttlMs,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { decidirElegibilidadeDaConversaViaSupabase } from "./consulta-supabase";
|
||||
|
||||
const ORG = "11111111-1111-4111-8111-111111111111";
|
||||
const CONV = "22222222-2222-4222-8222-222222222222";
|
||||
const AGORA = new Date("2026-08-27T12:00:00Z");
|
||||
const DIA = 24 * 60 * 60 * 1000;
|
||||
const TTL = 21 * DIA;
|
||||
|
||||
/**
|
||||
* Dublê mínimo do supabase-js: `.from().select().eq().eq().maybeSingle()`.
|
||||
* `resposta` é o que `maybeSingle` devolve.
|
||||
*/
|
||||
function adminStub(resposta: { data: unknown; error: { message: string } | null }) {
|
||||
const chain = {
|
||||
select: () => chain,
|
||||
eq: () => chain,
|
||||
maybeSingle: () => Promise.resolve(resposta),
|
||||
};
|
||||
return { from: vi.fn(() => chain) } as never;
|
||||
}
|
||||
|
||||
function linha(over: Record<string, unknown> = {}) {
|
||||
return {
|
||||
bot_silenced_until: null,
|
||||
assignee_kind: "ai",
|
||||
contacts: { force_human: false, ai_authorized_at: null },
|
||||
channel_sessions: { metadata: {} },
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("decidirElegibilidadeDaConversaViaSupabase", () => {
|
||||
it("canal 'open' (sem ai_gate): permite mesmo sem autorização", async () => {
|
||||
const d = await decidirElegibilidadeDaConversaViaSupabase(adminStub({ data: linha(), error: null }), {
|
||||
organizationId: ORG,
|
||||
conversationId: CONV,
|
||||
agora: AGORA,
|
||||
ttlMs: TTL,
|
||||
});
|
||||
expect(d).toEqual({ permite: true, motivo: "gate_aberto", bloqueioPorAllowlist: false });
|
||||
});
|
||||
|
||||
it("canal 'allowlist' + contato NÃO autorizado: NÃO permite (bloqueioPorAllowlist)", async () => {
|
||||
const d = await decidirElegibilidadeDaConversaViaSupabase(
|
||||
adminStub({
|
||||
data: linha({ channel_sessions: { metadata: { ai_gate: "allowlist" } } }),
|
||||
error: null,
|
||||
}),
|
||||
{ organizationId: ORG, conversationId: CONV, agora: AGORA, ttlMs: TTL },
|
||||
);
|
||||
expect(d).toMatchObject({ permite: false, motivo: "sem_autorizacao", bloqueioPorAllowlist: true });
|
||||
});
|
||||
|
||||
it("canal 'allowlist' + autorizado dentro da janela: permite", async () => {
|
||||
const d = await decidirElegibilidadeDaConversaViaSupabase(
|
||||
adminStub({
|
||||
data: linha({
|
||||
channel_sessions: { metadata: { ai_gate: "allowlist" } },
|
||||
contacts: {
|
||||
force_human: false,
|
||||
ai_authorized_at: new Date(AGORA.getTime() - 3 * DIA).toISOString(),
|
||||
},
|
||||
}),
|
||||
error: null,
|
||||
}),
|
||||
{ organizationId: ORG, conversationId: CONV, agora: AGORA, ttlMs: TTL },
|
||||
);
|
||||
expect(d).toMatchObject({ permite: true, motivo: "autorizado" });
|
||||
});
|
||||
|
||||
it("canal 'allowlist' + autorização expirada: NÃO permite (bloqueioPorAllowlist)", async () => {
|
||||
const d = await decidirElegibilidadeDaConversaViaSupabase(
|
||||
adminStub({
|
||||
data: linha({
|
||||
channel_sessions: { metadata: { ai_gate: "allowlist" } },
|
||||
contacts: {
|
||||
force_human: false,
|
||||
ai_authorized_at: new Date(AGORA.getTime() - 40 * DIA).toISOString(),
|
||||
},
|
||||
}),
|
||||
error: null,
|
||||
}),
|
||||
{ organizationId: ORG, conversationId: CONV, agora: AGORA, ttlMs: TTL },
|
||||
);
|
||||
expect(d).toMatchObject({ permite: false, motivo: "autorizacao_expirada", bloqueioPorAllowlist: true });
|
||||
});
|
||||
|
||||
it("bot_silenced_until='infinity' (handoff/pausa manual): NÃO permite, e NÃO é bloqueioPorAllowlist", async () => {
|
||||
const d = await decidirElegibilidadeDaConversaViaSupabase(
|
||||
adminStub({ data: linha({ bot_silenced_until: "infinity" }), error: null }),
|
||||
{ organizationId: ORG, conversationId: CONV, agora: AGORA, ttlMs: TTL },
|
||||
);
|
||||
expect(d).toMatchObject({ permite: false, motivo: "conversa_silenciada", bloqueioPorAllowlist: false });
|
||||
});
|
||||
|
||||
it("force_human do contato: NÃO permite em qualquer canal", async () => {
|
||||
const d = await decidirElegibilidadeDaConversaViaSupabase(
|
||||
adminStub({
|
||||
data: linha({ contacts: { force_human: true, ai_authorized_at: null } }),
|
||||
error: null,
|
||||
}),
|
||||
{ organizationId: ORG, conversationId: CONV, agora: AGORA, ttlMs: TTL },
|
||||
);
|
||||
expect(d).toMatchObject({ permite: false, motivo: "force_human" });
|
||||
});
|
||||
|
||||
it("conversa inexistente → null", async () => {
|
||||
const d = await decidirElegibilidadeDaConversaViaSupabase(adminStub({ data: null, error: null }), {
|
||||
organizationId: ORG,
|
||||
conversationId: CONV,
|
||||
agora: AGORA,
|
||||
ttlMs: TTL,
|
||||
});
|
||||
expect(d).toBeNull();
|
||||
});
|
||||
|
||||
it("erro de banco → LANÇA (fail-closed: o chamador trata como 'não responder')", async () => {
|
||||
await expect(
|
||||
decidirElegibilidadeDaConversaViaSupabase(
|
||||
adminStub({ data: null, error: { message: "column contacts.ai_authorized_at does not exist" } }),
|
||||
{ organizationId: ORG, conversationId: CONV, agora: AGORA, ttlMs: TTL },
|
||||
),
|
||||
).rejects.toThrow(/ai_authorized_at/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Ler o estado de elegibilidade de uma conversa via `supabase-js` (service role).
|
||||
*
|
||||
* Espelho de `consulta-pg.ts` para os caminhos que NÃO têm um pool `pg` à mão —
|
||||
* o worker legado (`workers/ai-response-worker.ts`), o envio inline de texto fixo
|
||||
* do follow-up (`lib/followup/enviar-texto-fixo.ts`), o worker de sentimento e o
|
||||
* orquestrador de handoff do lado do CRM. TODOS têm de respeitar o MESMO gate
|
||||
* que o drain e o turno do agent-engine: nenhum caminho pode mandar mensagem de
|
||||
* IA para uma conversa que uma origem elegível não autorizou.
|
||||
*
|
||||
* A regra pura (`decidirElegibilidade`) e a normalização
|
||||
* (`montarEstadoDeElegibilidade`) são as mesmas dos dois lados — só o transporte
|
||||
* muda.
|
||||
*
|
||||
* ─── Fail-closed ───────────────────────────────────────────────────────────
|
||||
*
|
||||
* `decidirElegibilidadeDaConversaViaSupabase` DEVOLVE a decisão, ou `null` só
|
||||
* quando a conversa não existe. Erro de query VIRA EXCEÇÃO — e cada chamador
|
||||
* destes caminhos secundários trata exceção como "não responder" (fail-closed):
|
||||
* são caminhos de retaguarda, o caminho robusto é o do agent-engine, e um erro
|
||||
* ao ler `contacts.ai_authorized_at` quase sempre é schema pela metade (imagem
|
||||
* nova, baseline ainda não aplicado) — exatamente quando NÃO se quer a IA solta.
|
||||
*/
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
|
||||
import {
|
||||
decidirElegibilidade,
|
||||
montarEstadoDeElegibilidade,
|
||||
type DecisaoDeElegibilidade,
|
||||
} from "./gate";
|
||||
|
||||
interface ConversaEmbed {
|
||||
bot_silenced_until: string | null;
|
||||
assignee_kind: string | null;
|
||||
contacts: { force_human: boolean | null; ai_authorized_at: string | null } | null;
|
||||
channel_sessions: { metadata: Record<string, unknown> | null } | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Roda a query e a regra. `null` = conversa não encontrada. Lança em erro de
|
||||
* banco — o chamador (caminho secundário) trata como "não responder".
|
||||
*/
|
||||
export async function decidirElegibilidadeDaConversaViaSupabase(
|
||||
admin: SupabaseClient,
|
||||
input: { organizationId: string; conversationId: string; agora: Date; ttlMs: number },
|
||||
): Promise<DecisaoDeElegibilidade | null> {
|
||||
const { data, error } = await admin
|
||||
.from("conversations")
|
||||
.select(
|
||||
"bot_silenced_until, assignee_kind, contacts:contact_id(force_human, ai_authorized_at), channel_sessions:channel_session_id(metadata)",
|
||||
)
|
||||
.eq("organization_id", input.organizationId)
|
||||
.eq("id", input.conversationId)
|
||||
.maybeSingle();
|
||||
|
||||
if (error) {
|
||||
throw new Error(`elegibilidade: leitura falhou — ${error.message}`);
|
||||
}
|
||||
if (data == null) return null;
|
||||
|
||||
const row = data as unknown as ConversaEmbed;
|
||||
return decidirElegibilidade(
|
||||
montarEstadoDeElegibilidade({
|
||||
aiGate: row.channel_sessions?.metadata?.["ai_gate"] ?? null,
|
||||
forceHuman: row.contacts?.force_human ?? false,
|
||||
assigneeKind: row.assignee_kind,
|
||||
botSilencedUntil: row.bot_silenced_until,
|
||||
aiAuthorizedAt: row.contacts?.ai_authorized_at ?? null,
|
||||
agora: input.agora,
|
||||
ttlMs: input.ttlMs,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
AI_ALLOWLIST_TTL_DAYS_DEFAULT,
|
||||
decidirElegibilidade,
|
||||
lerModoDoGate,
|
||||
ttlDaAutorizacaoMs,
|
||||
type EstadoDeElegibilidade,
|
||||
} from "./gate";
|
||||
|
||||
const AGORA = new Date("2026-08-27T12:00:00Z");
|
||||
const DIA = 24 * 60 * 60 * 1000;
|
||||
|
||||
const base: EstadoDeElegibilidade = {
|
||||
modo: "open",
|
||||
forceHuman: false,
|
||||
botSilencedUntil: null,
|
||||
assigneeKind: "ai",
|
||||
aiAuthorizedAt: null,
|
||||
agora: AGORA,
|
||||
ttlMs: 21 * DIA,
|
||||
};
|
||||
|
||||
describe("lerModoDoGate", () => {
|
||||
it("só 'allowlist' liga o modo; resto (ausente/null/lixo) é 'open'", () => {
|
||||
expect(lerModoDoGate("allowlist")).toBe("allowlist");
|
||||
expect(lerModoDoGate("open")).toBe("open");
|
||||
expect(lerModoDoGate(undefined)).toBe("open");
|
||||
expect(lerModoDoGate(null)).toBe("open");
|
||||
expect(lerModoDoGate("ALLOWLIST")).toBe("open");
|
||||
expect(lerModoDoGate(42)).toBe("open");
|
||||
});
|
||||
});
|
||||
|
||||
describe("decidirElegibilidade — gate 'open' (comportamento de hoje)", () => {
|
||||
it("mensagem genérica sem autorização: PERMITE (nada muda para quem não configurou)", () => {
|
||||
const d = decidirElegibilidade({ ...base, modo: "open" });
|
||||
expect(d.permite).toBe(true);
|
||||
expect(d.motivo).toBe("gate_aberto");
|
||||
});
|
||||
|
||||
it("force_human bloqueia mesmo com gate aberto", () => {
|
||||
const d = decidirElegibilidade({ ...base, modo: "open", forceHuman: true });
|
||||
expect(d.permite).toBe(false);
|
||||
expect(d.motivo).toBe("force_human");
|
||||
expect(d.bloqueioPorAllowlist).toBe(false);
|
||||
});
|
||||
|
||||
it("conversa com dono humano bloqueia mesmo com gate aberto", () => {
|
||||
const d = decidirElegibilidade({ ...base, modo: "open", assigneeKind: "user" });
|
||||
expect(d.permite).toBe(false);
|
||||
expect(d.motivo).toBe("conversa_de_humano");
|
||||
});
|
||||
|
||||
it("bot silenciado até o futuro bloqueia; silêncio no passado não", () => {
|
||||
expect(
|
||||
decidirElegibilidade({ ...base, modo: "open", botSilencedUntil: new Date(AGORA.getTime() + DIA) }).permite,
|
||||
).toBe(false);
|
||||
expect(
|
||||
decidirElegibilidade({ ...base, modo: "open", botSilencedUntil: new Date(AGORA.getTime() - DIA) }).permite,
|
||||
).toBe(true);
|
||||
expect(
|
||||
decidirElegibilidade({ ...base, modo: "open", botSilencedUntil: Number.POSITIVE_INFINITY }).permite,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("decidirElegibilidade — gate 'allowlist' (deny by default)", () => {
|
||||
it("teste 1/3/4/9: mensagem comum de contato NÃO autorizado → NÃO responde", () => {
|
||||
const d = decidirElegibilidade({ ...base, modo: "allowlist", aiAuthorizedAt: null });
|
||||
expect(d.permite).toBe(false);
|
||||
expect(d.motivo).toBe("sem_autorizacao");
|
||||
expect(d.bloqueioPorAllowlist).toBe(true);
|
||||
});
|
||||
|
||||
it("teste 6/7: contato autorizado dentro da janela → responde", () => {
|
||||
const d = decidirElegibilidade({
|
||||
...base,
|
||||
modo: "allowlist",
|
||||
aiAuthorizedAt: new Date(AGORA.getTime() - 3 * DIA),
|
||||
});
|
||||
expect(d.permite).toBe(true);
|
||||
expect(d.motivo).toBe("autorizado");
|
||||
});
|
||||
|
||||
it("submissão antiga (fora da janela) NÃO reativa a IA", () => {
|
||||
const d = decidirElegibilidade({
|
||||
...base,
|
||||
modo: "allowlist",
|
||||
aiAuthorizedAt: new Date(AGORA.getTime() - 30 * DIA),
|
||||
ttlMs: 21 * DIA,
|
||||
});
|
||||
expect(d.permite).toBe(false);
|
||||
expect(d.motivo).toBe("autorizacao_expirada");
|
||||
expect(d.bloqueioPorAllowlist).toBe(true);
|
||||
});
|
||||
|
||||
it("teste 10: conversa marcada human_only (force_human) → nunca responde, mesmo autorizada", () => {
|
||||
const d = decidirElegibilidade({
|
||||
...base,
|
||||
modo: "allowlist",
|
||||
forceHuman: true,
|
||||
aiAuthorizedAt: new Date(AGORA.getTime() - DIA),
|
||||
});
|
||||
expect(d.permite).toBe(false);
|
||||
expect(d.motivo).toBe("force_human");
|
||||
});
|
||||
|
||||
it("teste 2: conversa aberta e autorizada → responde (estado da conversa não pesa)", () => {
|
||||
const d = decidirElegibilidade({
|
||||
...base,
|
||||
modo: "allowlist",
|
||||
assigneeKind: "ai",
|
||||
aiAuthorizedAt: new Date(AGORA.getTime() - DIA),
|
||||
});
|
||||
expect(d.permite).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ttlDaAutorizacaoMs", () => {
|
||||
it("default quando ausente/vazio/inválido/zero/negativo", () => {
|
||||
const esperado = AI_ALLOWLIST_TTL_DAYS_DEFAULT * DIA;
|
||||
expect(ttlDaAutorizacaoMs({})).toBe(esperado);
|
||||
expect(ttlDaAutorizacaoMs({ AI_ALLOWLIST_TTL_DAYS: "" })).toBe(esperado);
|
||||
expect(ttlDaAutorizacaoMs({ AI_ALLOWLIST_TTL_DAYS: "abc" })).toBe(esperado);
|
||||
expect(ttlDaAutorizacaoMs({ AI_ALLOWLIST_TTL_DAYS: "0" })).toBe(esperado);
|
||||
expect(ttlDaAutorizacaoMs({ AI_ALLOWLIST_TTL_DAYS: "-5" })).toBe(esperado);
|
||||
});
|
||||
it("respeita o valor configurado", () => {
|
||||
expect(ttlDaAutorizacaoMs({ AI_ALLOWLIST_TTL_DAYS: "7" })).toBe(7 * DIA);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* O GATE DE ELEGIBILIDADE DA IA — "deny by default" por canal.
|
||||
*
|
||||
* ─── Por que este arquivo existe ────────────────────────────────────────────
|
||||
*
|
||||
* O DeskcommCRM responde `allow by default`: publicou um agente para a sessão de
|
||||
* WhatsApp, a IA atende TODO mundo que mandar mensagem. Isso é o certo para o
|
||||
* lojista cujo número existe só para vender. É o ERRADO para quem usa o mesmo
|
||||
* número para falar com cliente atual, fornecedor e contato pessoal — a IA
|
||||
* assume conversa que era de gente.
|
||||
*
|
||||
* O gate é OPT-IN, por canal: `channel_sessions.metadata.ai_gate`.
|
||||
*
|
||||
* 'open' (ausente / default) → comportamento de hoje, nenhuma checagem nova.
|
||||
* 'allowlist' → a IA só responde quando há uma condição
|
||||
* POSITIVA de elegibilidade no contato.
|
||||
*
|
||||
* ─── A regra é pura ─────────────────────────────────────────────────────────
|
||||
*
|
||||
* `decidirElegibilidade` não toca banco: recebe o estado já lido (gate do canal,
|
||||
* flags do contato, silêncio da conversa) e devolve `permite` + `motivo`. Os
|
||||
* dois consumidores — o drain (`lib/agent-engine/edge/crm/drain.ts`, que decide
|
||||
* ENFILEIRAR) e o turno (`lib/agent-engine/agent/inbound-turn.ts`, que decide
|
||||
* RODAR) — chamam a MESMA função. Regra duplicada nos dois lados divergiria na
|
||||
* primeira vez que alguém acrescentasse um motivo.
|
||||
*
|
||||
* ─── O que NÃO torna um contato elegível ────────────────────────────────────
|
||||
*
|
||||
* Mensagem nova sozinha. Conversa aberta. Conversa sem responsável. Conversa
|
||||
* aguardando resposta. Histórico. Cliente antigo. `channel_session` existir.
|
||||
* Nada disso. Só `contacts.ai_authorized_at` — carimbado por uma origem
|
||||
* elegível (webhook do Respondi, match de campanha, ação de automação, retomada
|
||||
* manual pela tela) e dentro da janela de validade.
|
||||
*/
|
||||
|
||||
/** Valores aceitos em `channel_sessions.metadata.ai_gate`. */
|
||||
export const AI_GATE_MODES = ["open", "allowlist"] as const;
|
||||
export type AiGateMode = (typeof AI_GATE_MODES)[number];
|
||||
|
||||
/**
|
||||
* Normaliza o valor cru do jsonb para um modo conhecido. Ausente, `null`,
|
||||
* string desconhecida → `'open'` (o padrão seguro: não muda o comportamento de
|
||||
* quem nunca configurou nada).
|
||||
*/
|
||||
export function lerModoDoGate(raw: unknown): AiGateMode {
|
||||
return raw === "allowlist" ? "allowlist" : "open";
|
||||
}
|
||||
|
||||
export interface EstadoDeElegibilidade {
|
||||
/** `channel_sessions.metadata.ai_gate` já normalizado. */
|
||||
modo: AiGateMode;
|
||||
/** `contacts.force_human` — a trava irrevogável pelo agente (regra dura 2). */
|
||||
forceHuman: boolean;
|
||||
/** `conversations.bot_silenced_until` (ou o da conversa em questão). `'infinity'` do Postgres vira `Infinity`. */
|
||||
botSilencedUntil: Date | number | null;
|
||||
/** `conversations.assignee_kind` — `'user'` = uma pessoa é a dona do thread. */
|
||||
assigneeKind: string | null;
|
||||
/** `contacts.ai_authorized_at`. `null` = nunca autorizado. */
|
||||
aiAuthorizedAt: Date | null;
|
||||
/** Agora, injetável para teste. */
|
||||
agora: Date;
|
||||
/** Janela de validade da autorização (`AI_ALLOWLIST_TTL_DAYS` em ms). */
|
||||
ttlMs: number;
|
||||
}
|
||||
|
||||
export type MotivoDeElegibilidade =
|
||||
| "gate_aberto"
|
||||
| "force_human"
|
||||
| "conversa_silenciada"
|
||||
| "conversa_de_humano"
|
||||
| "sem_autorizacao"
|
||||
| "autorizacao_expirada"
|
||||
| "autorizado";
|
||||
|
||||
export interface DecisaoDeElegibilidade {
|
||||
permite: boolean;
|
||||
motivo: MotivoDeElegibilidade;
|
||||
/**
|
||||
* `true` quando o motivo do NÃO é específico do gate 'allowlist'
|
||||
* (sem_autorizacao / autorizacao_expirada). O drain usa isto para não gastar
|
||||
* um job; o turno, para logar como "conversa não autorizada" e não como erro.
|
||||
*/
|
||||
bloqueioPorAllowlist: boolean;
|
||||
}
|
||||
|
||||
function silenciadoAgora(until: Date | number | null, agora: Date): boolean {
|
||||
if (until === null) return false;
|
||||
const t = typeof until === "number" ? until : until.getTime();
|
||||
return t > agora.getTime();
|
||||
}
|
||||
|
||||
/**
|
||||
* A decisão. Vetos que valem SEMPRE (mesmo com o gate aberto) vêm primeiro —
|
||||
* eles já eram lidos pelo motor (`isLeadInHandoff`, `skip("assigned_to_human")`)
|
||||
* e continuam valendo. O gate 'allowlist' só ACRESCENTA a exigência de
|
||||
* autorização positiva.
|
||||
*/
|
||||
export function decidirElegibilidade(e: EstadoDeElegibilidade): DecisaoDeElegibilidade {
|
||||
if (e.forceHuman) {
|
||||
return { permite: false, motivo: "force_human", bloqueioPorAllowlist: false };
|
||||
}
|
||||
if (silenciadoAgora(e.botSilencedUntil, e.agora)) {
|
||||
return { permite: false, motivo: "conversa_silenciada", bloqueioPorAllowlist: false };
|
||||
}
|
||||
if (e.assigneeKind === "user") {
|
||||
return { permite: false, motivo: "conversa_de_humano", bloqueioPorAllowlist: false };
|
||||
}
|
||||
|
||||
if (e.modo === "open") {
|
||||
return { permite: true, motivo: "gate_aberto", bloqueioPorAllowlist: false };
|
||||
}
|
||||
|
||||
// modo 'allowlist': exige condição positiva.
|
||||
if (e.aiAuthorizedAt === null) {
|
||||
return { permite: false, motivo: "sem_autorizacao", bloqueioPorAllowlist: true };
|
||||
}
|
||||
const idadeMs = e.agora.getTime() - e.aiAuthorizedAt.getTime();
|
||||
if (idadeMs > e.ttlMs) {
|
||||
return { permite: false, motivo: "autorizacao_expirada", bloqueioPorAllowlist: true };
|
||||
}
|
||||
return { permite: true, motivo: "autorizado", bloqueioPorAllowlist: false };
|
||||
}
|
||||
|
||||
/** Default da janela de validade da autorização, em dias. Knob: `AI_ALLOWLIST_TTL_DAYS`. */
|
||||
export const AI_ALLOWLIST_TTL_DAYS_DEFAULT = 21;
|
||||
|
||||
/** Lê o knob do ambiente (dias → ms). Valor ausente/inválido → default. */
|
||||
export function ttlDaAutorizacaoMs(env: Record<string, string | undefined>): number {
|
||||
const raw = env.AI_ALLOWLIST_TTL_DAYS;
|
||||
const dias = raw !== undefined && raw !== "" ? Number(raw) : NaN;
|
||||
const efetivo = Number.isFinite(dias) && dias > 0 ? dias : AI_ALLOWLIST_TTL_DAYS_DEFAULT;
|
||||
return efetivo * 24 * 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normaliza um instante cru de qualquer transporte para o que `silenciadoAgora`
|
||||
* entende: `Date`, `Infinity` (o `'infinity'` do Postgres, que o `pg` devolve
|
||||
* como string e o supabase-js também) ou `null`. String de data inválida → `null`.
|
||||
*/
|
||||
export function normalizarInstante(v: Date | string | number | null | undefined): Date | number | null {
|
||||
if (v === null || v === undefined) return null;
|
||||
if (v instanceof Date) return v;
|
||||
if (typeof v === "number") return v;
|
||||
if (v === "infinity") return Number.POSITIVE_INFINITY;
|
||||
if (v === "-infinity") return Number.NEGATIVE_INFINITY;
|
||||
const d = new Date(v);
|
||||
return Number.isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
|
||||
/**
|
||||
* Monta o `EstadoDeElegibilidade` a partir dos campos crus lidos do banco — a
|
||||
* MESMA normalização para os dois transportes (`consulta-pg.ts` via pool `pg` e
|
||||
* `consulta-supabase.ts` via supabase-js). Uma cópia por transporte divergiria
|
||||
* na primeira vez que alguém tratasse `'infinity'` num lado e esquecesse do
|
||||
* outro. Não roda a regra — só normaliza; quem decide é `decidirElegibilidade`.
|
||||
*/
|
||||
export function montarEstadoDeElegibilidade(raw: {
|
||||
aiGate: unknown;
|
||||
forceHuman: unknown;
|
||||
assigneeKind: string | null;
|
||||
botSilencedUntil: Date | string | number | null | undefined;
|
||||
aiAuthorizedAt: Date | string | null | undefined;
|
||||
agora: Date;
|
||||
ttlMs: number;
|
||||
}): EstadoDeElegibilidade {
|
||||
const autorizadoEm = normalizarInstante(raw.aiAuthorizedAt);
|
||||
return {
|
||||
modo: lerModoDoGate(raw.aiGate),
|
||||
forceHuman: raw.forceHuman === true,
|
||||
botSilencedUntil: normalizarInstante(raw.botSilencedUntil),
|
||||
assigneeKind: raw.assigneeKind,
|
||||
aiAuthorizedAt: autorizadoEm instanceof Date ? autorizadoEm : null,
|
||||
agora: raw.agora,
|
||||
ttlMs: raw.ttlMs,
|
||||
};
|
||||
}
|
||||
@@ -27,6 +27,8 @@
|
||||
import { createAdminClient } from "@/lib/supabase/admin";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { moverLeadParaEtapaDeHandoff } from "@/lib/leads/handoff-stage-move";
|
||||
import { decidirElegibilidadeDaConversaViaSupabase } from "@/lib/ai/elegibilidade/consulta-supabase";
|
||||
import { ttlDaAutorizacaoMs } from "@/lib/ai/elegibilidade/gate";
|
||||
|
||||
import { avisarLeadDoCrm } from "./aviso-ao-lead";
|
||||
|
||||
@@ -100,6 +102,32 @@ export async function triggerHandoff(
|
||||
}
|
||||
}
|
||||
|
||||
// GATE DE ELEGIBILIDADE — só se passa bot→humano uma conversa que a IA
|
||||
// PODERIA estar atendendo agora. Se `decidirElegibilidade` já diz não —
|
||||
// porque o gate `allowlist` barra o contato (cliente antigo irritado →
|
||||
// `low_sentiment` do worker de sentimento), OU porque já está de forma
|
||||
// duradoura silenciada/em handoff/com dono humano —, NÃO há o que passar: disparar
|
||||
// mandaria "um humano vai te atender" (às vezes de novo) e mexeria no
|
||||
// estado de uma conversa que não é da IA. Fail-closed: erro de leitura →
|
||||
// não dispara (o evento re-tenta).
|
||||
try {
|
||||
const elegib = await decidirElegibilidadeDaConversaViaSupabase(admin, {
|
||||
organizationId: input.organizationId,
|
||||
conversationId: input.conversationId,
|
||||
agora: new Date(),
|
||||
ttlMs: ttlDaAutorizacaoMs(process.env),
|
||||
});
|
||||
if (elegib !== null && !elegib.permite) {
|
||||
return { triggered: false, reason: `nao_elegivel:${elegib.motivo}` };
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn("[handoff] elegibilidade indeterminada — handoff não disparado", {
|
||||
conversation_id: input.conversationId,
|
||||
detail: err instanceof Error ? err.message.slice(0, 160) : "erro",
|
||||
});
|
||||
return { triggered: false, reason: "elegibilidade_indeterminada" };
|
||||
}
|
||||
|
||||
const nowIso = new Date().toISOString();
|
||||
|
||||
// Step 0 — AVISA O LEAD. Antes de tudo, e este é o passo que faltava.
|
||||
|
||||
@@ -34,6 +34,8 @@ import {
|
||||
OPENROUTER_ENDPOINT,
|
||||
} from "@/lib/agent-engine/edge/llm/providers";
|
||||
import { CredentialUnavailableError, loadCredential } from "@/lib/ai/credentials";
|
||||
import { decidirElegibilidadeDaConversaViaSupabase } from "@/lib/ai/elegibilidade/consulta-supabase";
|
||||
import { ttlDaAutorizacaoMs } from "@/lib/ai/elegibilidade/gate";
|
||||
import { createAdminClient } from "@/lib/supabase/admin";
|
||||
import { audit } from "@/lib/audit";
|
||||
import type { McpAuthResult } from "@/lib/mcp/auth";
|
||||
@@ -369,6 +371,30 @@ export async function runAgent(input: RunAgentInput): Promise<RunAgentResult> {
|
||||
waLid: conv.contacts?.wa_lid,
|
||||
});
|
||||
}
|
||||
|
||||
// GATE DE ELEGIBILIDADE — este runtime legado (@deprecated, hoje só o
|
||||
// dispatcher aposentado o alcança com envio real) TAMBÉM não pode
|
||||
// responder uma conversa que uma origem elegível não autorizou. Mesma
|
||||
// regra pura do drain/turno. Fail-closed: erro de leitura → falha o run
|
||||
// antes de qualquer custo de LLM.
|
||||
try {
|
||||
const elegib = await decidirElegibilidadeDaConversaViaSupabase(admin, {
|
||||
organizationId: run.organization_id,
|
||||
conversationId: run.conversation_id,
|
||||
agora: new Date(),
|
||||
ttlMs: ttlDaAutorizacaoMs(process.env),
|
||||
});
|
||||
if (elegib !== null && !elegib.permite) {
|
||||
return await failRun(run, "nao_elegivel_para_ia", `elegibilidade: ${elegib.motivo}`, startedAt);
|
||||
}
|
||||
} catch (err) {
|
||||
return await failRun(
|
||||
run,
|
||||
"nao_elegivel_para_ia",
|
||||
`elegibilidade indeterminada: ${err instanceof Error ? err.message.slice(0, 120) : "erro"}`,
|
||||
startedAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!inboundBody) {
|
||||
|
||||
+13
-1
@@ -59,7 +59,19 @@ export type SkipReason =
|
||||
* rodam na mesma mensagem: o engine responde de verdade e este aqui gasta
|
||||
* token à toa e deixa uma linha presa para sempre no inbox de quem instalou.
|
||||
*/
|
||||
| "engine_owns_reply";
|
||||
| "engine_owns_reply"
|
||||
/**
|
||||
* O canal tem o gate de elegibilidade ligado (`channel_sessions.metadata.ai_gate
|
||||
* = 'allowlist'`) e o contato NÃO foi autorizado por uma origem elegível
|
||||
* (webhook do Respondi, match de campanha, ação de automação, retomada manual)
|
||||
* — ou a autorização expirou. Este worker legado passa pela MESMA regra pura
|
||||
* (`lib/ai/elegibilidade/gate.ts`) que o drain e o turno do agent-engine: não
|
||||
* pode existir um caminho alternativo que responda uma conversa não
|
||||
* autorizada. Também cai aqui quando a leitura da elegibilidade falha —
|
||||
* fail-closed, porque schema pela metade é exatamente quando não se quer a IA
|
||||
* solta.
|
||||
*/
|
||||
| "nao_elegivel_para_ia";
|
||||
|
||||
export interface BotContext {
|
||||
organization_id: string;
|
||||
|
||||
@@ -26,6 +26,7 @@ import { getRequestPool } from "@/lib/agent-engine/db/request-pool";
|
||||
import { llmEdgeConfigFromEnv } from "@/lib/agent-engine/edge/llm/credentials";
|
||||
import { env } from "@/lib/env";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { autorizarContatoParaIA } from "@/lib/ai/elegibilidade/autorizacao";
|
||||
|
||||
const TIPO = "send_ai_message";
|
||||
|
||||
@@ -102,6 +103,15 @@ async function execute(ctx: ActionCtx, config: Record<string, unknown>): Promise
|
||||
// ─── O envio ───────────────────────────────────────────────────────────────
|
||||
try {
|
||||
const conversationId = await ensureConversation(ctx.admin, ctx.organizationId, contact.id, sessionId);
|
||||
// ELEGIBILIDADE: a IA vai FALAR com este contato agora, por decisão de uma
|
||||
// regra de automação (tipicamente o `lead.created` de um formulário). Isso o
|
||||
// torna elegível para a resposta dele ser atendida — sem isto, no gate
|
||||
// `allowlist` a IA abriria a conversa e ignoraria o retorno do lead.
|
||||
await autorizarContatoParaIA(ctx.admin, {
|
||||
organizationId: ctx.organizationId,
|
||||
contactId: contact.id,
|
||||
reason: `automacao:${ctx.ruleId}`,
|
||||
});
|
||||
await espacarEnvio(sessionId);
|
||||
const message = await sendMessageHandler(
|
||||
ctx.admin,
|
||||
|
||||
@@ -44,6 +44,8 @@ import { logger } from "@/lib/logger";
|
||||
import type { createAdminClient } from "@/lib/supabase/admin";
|
||||
import { ehPedidoDeOptOut } from "@/lib/opt-out/deteccao";
|
||||
import { acelerarPipelineDeEventos } from "@/lib/dev/kick-local-pipeline";
|
||||
import { autorizarContatoParaIA } from "@/lib/ai/elegibilidade/autorizacao";
|
||||
import { casarCampanha, lerCampanhas } from "@/lib/ai/elegibilidade/campanha";
|
||||
|
||||
type Admin = ReturnType<typeof createAdminClient>;
|
||||
|
||||
@@ -115,6 +117,7 @@ export async function aplicarEfeitosPosEntrada(
|
||||
): Promise<void> {
|
||||
await aplicarOptOut(admin, entrada);
|
||||
await abrirDemanda(admin, entrada);
|
||||
await avaliarCampanha(admin, entrada);
|
||||
// A resposta do lead avança o follow-up AQUI. O despacho do agente (LLM)
|
||||
// vem depois: no Hobby ele estoura o tempo da request e o próximo texto
|
||||
// do fluxo ficava esperando o relógio.
|
||||
@@ -127,6 +130,69 @@ export async function aplicarEfeitosPosEntrada(
|
||||
await pedirDespachoDoAgente(admin, entrada);
|
||||
}
|
||||
|
||||
/**
|
||||
* 2b · A mensagem casa uma campanha registrada? (caso 2 da elegibilidade)
|
||||
*
|
||||
* Campanhas de Meta/Google que levam direto para o WhatsApp: o lead chega com
|
||||
* uma mensagem identificadora ("Quero saber mais sobre X"). Se ela casar uma
|
||||
* campanha em `organizations.settings.campanhas_whatsapp`, o contato fica
|
||||
* elegível para a IA. Só faz sentido consultar quando o canal tem o gate
|
||||
* `allowlist` ligado — no gate 'open' a IA já responde todo mundo. Roda ANTES do
|
||||
* despacho: o evento `ai_agent.dispatch_requested` desta mesma mensagem precisa
|
||||
* já encontrar o contato autorizado.
|
||||
*
|
||||
* Best-effort: qualquer falha aqui vira log e o despacho segue (e cai no
|
||||
* atendimento humano, o lado seguro).
|
||||
*/
|
||||
async function avaliarCampanha(admin: Admin, entrada: EntradaDeMensagem): Promise<void> {
|
||||
if (!entrada.texto || entrada.texto.trim() === "") return;
|
||||
try {
|
||||
const { data: sess } = await admin
|
||||
.from("channel_sessions")
|
||||
.select("metadata")
|
||||
.eq("organization_id", entrada.organizationId)
|
||||
.eq("id", entrada.channelSessionId)
|
||||
.maybeSingle();
|
||||
const gate = (sess?.metadata as Record<string, unknown> | null)?.ai_gate;
|
||||
if (gate !== "allowlist") return;
|
||||
|
||||
const { data: contato } = await admin
|
||||
.from("contacts")
|
||||
.select("ai_authorized_at")
|
||||
.eq("organization_id", entrada.organizationId)
|
||||
.eq("id", entrada.contactId)
|
||||
.maybeSingle();
|
||||
if (contato?.ai_authorized_at != null) return; // já elegível — não reescreve a origem
|
||||
|
||||
const { data: org } = await admin
|
||||
.from("organizations")
|
||||
.select("settings")
|
||||
.eq("id", entrada.organizationId)
|
||||
.maybeSingle();
|
||||
const campanhas = lerCampanhas(org?.settings ?? null);
|
||||
const casada = casarCampanha(entrada.texto, campanhas, entrada.channelSessionId);
|
||||
if (casada === null) return;
|
||||
|
||||
await autorizarContatoParaIA(admin, {
|
||||
organizationId: entrada.organizationId,
|
||||
contactId: entrada.contactId,
|
||||
reason: `campanha:${casada.id}`,
|
||||
apenasSeNaoAutorizado: true,
|
||||
});
|
||||
logger.info("pos-entrada: contato autorizado para IA por campanha", {
|
||||
organization_id: entrada.organizationId,
|
||||
conversation_id: entrada.conversationId,
|
||||
campanha: casada.id,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn("pos-entrada: avaliação de campanha falhou (o despacho segue)", {
|
||||
organization_id: entrada.organizationId,
|
||||
conversation_id: entrada.conversationId,
|
||||
detail: err instanceof Error ? err.message.slice(0, 160) : "desconhecido",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 1 · Quem pediu para sair, sai.
|
||||
*
|
||||
|
||||
@@ -28,6 +28,7 @@ import { canonicalPhoneBR } from "@/lib/channels/phone-variants";
|
||||
|
||||
import { extrairAtribuicaoMeta } from "@/lib/channels/atribuicao-de-anuncio-oficial";
|
||||
import { estamparAtribuicaoDoContato } from "@/lib/leads/atribuicao-de-anuncio";
|
||||
import { pausarIaPorAtendimentoManual } from "@/lib/escalacao/atendimento-manual";
|
||||
|
||||
import { aplicarEfeitosPosEntrada } from "../pos-entrada";
|
||||
|
||||
@@ -174,6 +175,18 @@ export async function ingestZernioInbound(
|
||||
await pedirPersistenciaDaMidia(admin, input.organizationId, conversationId, inserted);
|
||||
}
|
||||
await efeitosDaEntrada(admin, input, msg, contactId, conversationId, inserted);
|
||||
|
||||
// SAÍDA feita por fora do CRM = uma pessoa respondeu o cliente à mão (celular,
|
||||
// outra plataforma na mesma conta). A IA para nesta conversa. O eco do nosso
|
||||
// próprio envio já saiu como `"duplicate"` acima. NÃO mexe na origem do lead.
|
||||
if (msg.direction === "outbound") {
|
||||
await pausarIaPorAtendimentoManual(admin, {
|
||||
organizationId: input.organizationId,
|
||||
conversationId,
|
||||
canal: "zernio",
|
||||
});
|
||||
}
|
||||
|
||||
return { status: "ingested", conversationId, messageId: inserted };
|
||||
}
|
||||
|
||||
|
||||
@@ -2546,6 +2546,8 @@ export type Database = {
|
||||
}
|
||||
contacts: {
|
||||
Row: {
|
||||
ai_authorized_at: string | null
|
||||
ai_authorized_reason: string | null
|
||||
anonymized_at: string | null
|
||||
avatar_storage_path: string | null
|
||||
avatar_updated_at: string | null
|
||||
@@ -2580,6 +2582,8 @@ export type Database = {
|
||||
wa_lid: string | null
|
||||
}
|
||||
Insert: {
|
||||
ai_authorized_at?: string | null
|
||||
ai_authorized_reason?: string | null
|
||||
anonymized_at?: string | null
|
||||
avatar_storage_path?: string | null
|
||||
avatar_updated_at?: string | null
|
||||
@@ -2614,6 +2618,8 @@ export type Database = {
|
||||
wa_lid?: string | null
|
||||
}
|
||||
Update: {
|
||||
ai_authorized_at?: string | null
|
||||
ai_authorized_reason?: string | null
|
||||
anonymized_at?: string | null
|
||||
avatar_storage_path?: string | null
|
||||
avatar_updated_at?: string | null
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { pausarIaPorAtendimentoManual } from "./atendimento-manual";
|
||||
|
||||
const ORG = "11111111-1111-4111-8111-111111111111";
|
||||
const CONV = "22222222-2222-4222-8222-222222222222";
|
||||
|
||||
/**
|
||||
* Dublê do supabase-js que registra os UPDATEs em `conversations`.
|
||||
* `silencedUntil` alimenta o SELECT inicial.
|
||||
*/
|
||||
function adminStub(silencedUntil: string | null) {
|
||||
const updates: Array<Record<string, unknown>> = [];
|
||||
const chain = {
|
||||
select: () => chain,
|
||||
update: (patch: Record<string, unknown>) => {
|
||||
updates.push(patch);
|
||||
return chain;
|
||||
},
|
||||
eq: () => chain,
|
||||
maybeSingle: () => Promise.resolve({ data: { bot_silenced_until: silencedUntil }, error: null }),
|
||||
then: (r: (v: unknown) => unknown) => Promise.resolve({ error: null }).then(r),
|
||||
};
|
||||
return { admin: { from: vi.fn(() => chain) } as never, updates };
|
||||
}
|
||||
|
||||
describe("pausarIaPorAtendimentoManual", () => {
|
||||
it("conversa ativa (sem silêncio): grava silêncio DURÁVEL + rastro de handoff", async () => {
|
||||
const { admin, updates } = adminStub(null);
|
||||
const pausou = await pausarIaPorAtendimentoManual(admin, {
|
||||
organizationId: ORG,
|
||||
conversationId: CONV,
|
||||
canal: "canal-x",
|
||||
});
|
||||
expect(pausou).toBe(true);
|
||||
expect(updates).toHaveLength(1);
|
||||
expect(updates[0]).toMatchObject({
|
||||
bot_silenced_until: "infinity",
|
||||
last_handoff_reason: expect.stringContaining("Atendimento manual"),
|
||||
});
|
||||
expect(updates[0]).toHaveProperty("last_handoff_at");
|
||||
});
|
||||
|
||||
it("NÃO toca ai_authorized_at / force_human / status / assignee_kind", async () => {
|
||||
const { admin, updates } = adminStub(null);
|
||||
await pausarIaPorAtendimentoManual(admin, { organizationId: ORG, conversationId: CONV });
|
||||
const patch = updates[0] ?? {};
|
||||
expect(patch).not.toHaveProperty("ai_authorized_at");
|
||||
expect(patch).not.toHaveProperty("ai_authorized_reason");
|
||||
expect(patch).not.toHaveProperty("force_human");
|
||||
expect(patch).not.toHaveProperty("status");
|
||||
expect(patch).not.toHaveProperty("assignee_kind");
|
||||
});
|
||||
|
||||
it("idempotente: já silenciada com 'infinity' → não re-carimba", async () => {
|
||||
const { admin, updates } = adminStub("infinity");
|
||||
const pausou = await pausarIaPorAtendimentoManual(admin, { organizationId: ORG, conversationId: CONV });
|
||||
expect(pausou).toBe(false);
|
||||
expect(updates).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("idempotente: já silenciada por janela futura → não re-carimba", async () => {
|
||||
const futuro = new Date(Date.now() + 5 * 60 * 1000).toISOString();
|
||||
const { admin, updates } = adminStub(futuro);
|
||||
const pausou = await pausarIaPorAtendimentoManual(admin, { organizationId: ORG, conversationId: CONV });
|
||||
expect(pausou).toBe(false);
|
||||
expect(updates).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("silêncio no PASSADO (expirado) → pausa de novo", async () => {
|
||||
const passado = new Date(Date.now() - 60 * 1000).toISOString();
|
||||
const { admin, updates } = adminStub(passado);
|
||||
const pausou = await pausarIaPorAtendimentoManual(admin, { organizationId: ORG, conversationId: CONV });
|
||||
expect(pausou).toBe(true);
|
||||
expect(updates).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("conversa inexistente → não faz nada", async () => {
|
||||
const chain = {
|
||||
select: () => chain,
|
||||
update: () => chain,
|
||||
eq: () => chain,
|
||||
maybeSingle: () => Promise.resolve({ data: null, error: null }),
|
||||
};
|
||||
const admin = { from: vi.fn(() => chain) } as never;
|
||||
const pausou = await pausarIaPorAtendimentoManual(admin, { organizationId: ORG, conversationId: CONV });
|
||||
expect(pausou).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* ATENDIMENTO MANUAL PELO CANAL — o dono pegou o celular e respondeu o cliente
|
||||
* direto no WhatsApp (ou por outra plataforma ligada à mesma conta). A IA para
|
||||
* NESSA conversa, para não responder junto.
|
||||
*
|
||||
* ## Por que existe
|
||||
*
|
||||
* `app/api/v1/messages/_handler.ts` (composer) já silencia o bot quando o ATOR é
|
||||
* uma pessoa — mas por uma janela deslizante de 5 min. O envio feito do celular
|
||||
* do operador NÃO passa por ali: ele entra pela ingestão de saída do canal (o
|
||||
* caminho `fromMe` do webhook, mensagem enviada fora do CRM) e era gravado como
|
||||
* histórico sem tocar em trava nenhuma. Resultado: a IA continuava respondendo
|
||||
* por cima de quem estava atendendo à mão.
|
||||
*
|
||||
* ## O que grava, e o que NÃO grava
|
||||
*
|
||||
* Igual ao `POST /conversations/[id]/pause-ai` (o botão "assumir"):
|
||||
* - `bot_silenced_until = 'infinity'` — silêncio DURÁVEL, o mesmo literal que
|
||||
* os três guards do motor e `decidirElegibilidade` já leem. Não é a janela
|
||||
* de 5 min: quem foi ao WhatsApp atender não quer a IA voltando sozinha no
|
||||
* meio. A volta é explícita — `reactivate-bot` / `devolverAtendimentoAoAgente`.
|
||||
* - `last_handoff_at` / `last_handoff_reason` — rastro visível de que uma
|
||||
* pessoa assumiu por fora.
|
||||
*
|
||||
* **NÃO toca `contacts.ai_authorized_at`.** A origem/autorização do lead é
|
||||
* estado SEPARADO (elegibilidade), não handoff. Uma resposta manual pausa a
|
||||
* conversa; não apaga que o lead veio do Respondi. Quando o humano devolve, a
|
||||
* autorização ainda está lá.
|
||||
*
|
||||
* **NÃO toca `contacts.force_human`** (trava do CONTATO inteiro — pausar uma
|
||||
* conversa não é bloquear o cliente) nem `assignee_kind` (exige um
|
||||
* `assigned_to_user_id`, e o celular do dono não é necessariamente um usuário do
|
||||
* CRM) nem `status` (mandar para `pending` diria "na fila esperando atendente",
|
||||
* o oposto de "estou atendendo").
|
||||
*
|
||||
* Idempotente: se a conversa JÁ está com silêncio no futuro (outro handoff, ou
|
||||
* este mesmo em mensagem anterior), não re-carimba.
|
||||
*
|
||||
* Fire-and-forget: a ingestão da mensagem do cliente não pode cair porque a
|
||||
* pausa falhou.
|
||||
*/
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
|
||||
import { logger } from "@/lib/logger";
|
||||
import { normalizarInstante } from "@/lib/ai/elegibilidade/gate";
|
||||
|
||||
/** O mesmo literal do handoff e do `pause-ai`: `bot_silenced_until > now()` sempre true. */
|
||||
const SILENCIO_DURAVEL = "infinity";
|
||||
const MOTIVO = "Atendimento manual pelo canal (resposta fora do CRM)";
|
||||
|
||||
export interface PausaPorAtendimentoManualInput {
|
||||
organizationId: string;
|
||||
conversationId: string;
|
||||
/** Rótulo da origem do evento, só para log (o adapter que chamou se identifica). */
|
||||
canal?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pausa a IA numa conversa porque uma pessoa respondeu por fora do CRM.
|
||||
* Devolve `true` se pausou agora, `false` se já estava pausada ou falhou.
|
||||
*/
|
||||
export async function pausarIaPorAtendimentoManual(
|
||||
admin: SupabaseClient,
|
||||
input: PausaPorAtendimentoManualInput,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const { data: atual, error: readErr } = await admin
|
||||
.from("conversations")
|
||||
.select("bot_silenced_until")
|
||||
.eq("organization_id", input.organizationId)
|
||||
.eq("id", input.conversationId)
|
||||
.maybeSingle();
|
||||
|
||||
if (readErr) {
|
||||
logger.warn("[atendimento-manual] leitura da conversa falhou — IA não pausada", {
|
||||
organization_id: input.organizationId,
|
||||
conversation_id: input.conversationId,
|
||||
detail: readErr.message.slice(0, 160),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
if (atual == null) return false;
|
||||
|
||||
// Já silenciada no futuro (infinity ou janela): não re-carimba o handoff.
|
||||
const silenciadaAte = normalizarInstante(
|
||||
(atual as { bot_silenced_until: string | null }).bot_silenced_until,
|
||||
);
|
||||
const agora = Date.now();
|
||||
const jaSilenciada =
|
||||
silenciadaAte === Number.POSITIVE_INFINITY ||
|
||||
(silenciadaAte instanceof Date && silenciadaAte.getTime() > agora) ||
|
||||
(typeof silenciadaAte === "number" && Number.isFinite(silenciadaAte) && silenciadaAte > agora);
|
||||
if (jaSilenciada) return false;
|
||||
|
||||
const nowIso = new Date().toISOString();
|
||||
const { error: updErr } = await admin
|
||||
.from("conversations")
|
||||
.update({
|
||||
bot_silenced_until: SILENCIO_DURAVEL,
|
||||
last_handoff_at: nowIso,
|
||||
last_handoff_reason: MOTIVO,
|
||||
})
|
||||
.eq("organization_id", input.organizationId)
|
||||
.eq("id", input.conversationId);
|
||||
|
||||
if (updErr) {
|
||||
logger.warn("[atendimento-manual] pausa da IA não gravada", {
|
||||
organization_id: input.organizationId,
|
||||
conversation_id: input.conversationId,
|
||||
detail: updErr.message.slice(0, 160),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
logger.info("[atendimento-manual] IA pausada — pessoa respondeu pelo canal", {
|
||||
organization_id: input.organizationId,
|
||||
conversation_id: input.conversationId,
|
||||
canal: input.canal ?? "desconhecido",
|
||||
});
|
||||
return true;
|
||||
} catch (err) {
|
||||
logger.warn("[atendimento-manual] pausa da IA lançou", {
|
||||
organization_id: input.organizationId,
|
||||
conversation_id: input.conversationId,
|
||||
detail: err instanceof Error ? err.message.slice(0, 160) : "erro",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ import { audit } from "@/lib/audit";
|
||||
import { emitLeadActivity } from "@/lib/leads/activity-emitter";
|
||||
import { resolveActiveLeadForContact, type LeadCandidate } from "@/lib/leads/active-lead";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { autorizarContatoParaIA } from "@/lib/ai/elegibilidade/autorizacao";
|
||||
|
||||
import { lerContinuidadeHumana, type ContinuidadeHumana } from "./continuidade";
|
||||
|
||||
@@ -167,6 +168,18 @@ export async function devolverAtendimentoAoAgente(
|
||||
}
|
||||
}
|
||||
|
||||
// (3b) ELEGIBILIDADE: devolver o atendimento à IA é uma decisão humana
|
||||
// explícita — no gate `allowlist`, é ela que RE-AUTORIZA o contato. Sem isto,
|
||||
// o botão "devolver ao automático" apagaria as três travas de handoff e a IA
|
||||
// continuaria muda, porque `contacts.ai_authorized_at` seguiria nulo/expirado.
|
||||
if (conv.contact_id !== null) {
|
||||
await autorizarContatoParaIA(supabase, {
|
||||
organizationId,
|
||||
contactId: conv.contact_id,
|
||||
reason: "retomada_manual",
|
||||
});
|
||||
}
|
||||
|
||||
// (4) Sinal durável de fim do episódio. AWAITED, não fire-and-forget, pela
|
||||
// mesma razão que a rota original documentava: é o ÚNICO produtor do sinal que
|
||||
// retoma um follow-up pausado por passagem a humano (lib/followup/reactivity.ts).
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* R1 — o envio INLINE de texto fixo do follow-up (`enviarTextoFixoPendente`, o
|
||||
* atalho "sem cron e sem agent-worker") BYPASSA `executarTurnoDoAgente`, então
|
||||
* precisa do gate de elegibilidade por conta própria. Sem isto, um fluxo de
|
||||
* follow-up com nó de texto fixo mandaria mensagem para uma conversa que o gate
|
||||
* `allowlist` barra.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const sendMessageHandler = vi.fn(async (..._a: unknown[]) => ({ id: "msg-1" }));
|
||||
const decidir = vi.fn();
|
||||
const completeTurnForEnrollment = vi.fn(async (..._a: unknown[]) => {});
|
||||
|
||||
vi.mock("@/app/api/v1/messages/_handler", () => ({ sendMessageHandler: (...a: unknown[]) => sendMessageHandler(...a) }));
|
||||
vi.mock("@/lib/automation/start-conversation", () => ({
|
||||
ensureConversation: async () => "conv-1",
|
||||
sessaoProntaParaEnvio: async () => "sess-1",
|
||||
}));
|
||||
vi.mock("@/lib/ai/elegibilidade/consulta-supabase", () => ({
|
||||
decidirElegibilidadeDaConversaViaSupabase: (...a: unknown[]) => decidir(...a),
|
||||
}));
|
||||
vi.mock("@/lib/followup/turn-bridge", () => ({
|
||||
completeTurnForEnrollment: (...a: unknown[]) => completeTurnForEnrollment(...a),
|
||||
}));
|
||||
vi.mock("@/lib/followup/engine", () => ({ createSupabaseAdminClient: () => ({}) }));
|
||||
vi.mock("@/lib/logger", () => ({ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() } }));
|
||||
|
||||
import { enviarTextoFixoPendente } from "./enviar-texto-fixo";
|
||||
|
||||
const JOB = {
|
||||
id: "job-1",
|
||||
organization_id: "org-1",
|
||||
contact_id: "contact-1",
|
||||
payload: { fixed_body: "Oi, tudo bem?", followup_enrollment_id: "enr-1", node_id: "node-1" },
|
||||
};
|
||||
|
||||
const statusUpdates: string[] = [];
|
||||
|
||||
/** Admin stub: job_queue (select pending / claim / status) + followup_enrollments. */
|
||||
function admin() {
|
||||
const make = (table: string) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const chain: any = {
|
||||
_table: table,
|
||||
_upd: null as Record<string, unknown> | null,
|
||||
select: () => chain,
|
||||
eq: () => chain,
|
||||
order: () => chain,
|
||||
limit: () => chain,
|
||||
update: (p: Record<string, unknown>) => {
|
||||
chain._upd = p;
|
||||
if (table === "job_queue" && typeof p.status === "string") statusUpdates.push(p.status);
|
||||
return chain;
|
||||
},
|
||||
maybeSingle: () => {
|
||||
if (table === "job_queue" && chain._upd) return Promise.resolve({ data: { id: JOB.id }, error: null });
|
||||
if (table === "followup_enrollments")
|
||||
return Promise.resolve({ data: { current_node_id: "node-1" }, error: null });
|
||||
return Promise.resolve({ data: null, error: null });
|
||||
},
|
||||
then: (r: (v: unknown) => unknown) => {
|
||||
if (table === "job_queue" && !chain._upd) {
|
||||
return Promise.resolve({ data: [JOB], error: null }).then(r);
|
||||
}
|
||||
return Promise.resolve({ data: null, error: null }).then(r);
|
||||
},
|
||||
};
|
||||
return chain;
|
||||
};
|
||||
return { from: (t: string) => make(t) } as never;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
statusUpdates.length = 0;
|
||||
});
|
||||
|
||||
describe("enviarTextoFixoPendente · gate de elegibilidade", () => {
|
||||
it("conversa NÃO elegível → NÃO envia, job vira 'done'", async () => {
|
||||
decidir.mockResolvedValue({ permite: false, motivo: "sem_autorizacao", bloqueioPorAllowlist: true });
|
||||
const enviados = await enviarTextoFixoPendente(admin());
|
||||
expect(enviados).toBe(0);
|
||||
expect(sendMessageHandler).not.toHaveBeenCalled();
|
||||
expect(statusUpdates).toContain("done");
|
||||
});
|
||||
|
||||
it("conversa elegível → envia normalmente", async () => {
|
||||
decidir.mockResolvedValue({ permite: true, motivo: "autorizado", bloqueioPorAllowlist: false });
|
||||
const enviados = await enviarTextoFixoPendente(admin());
|
||||
expect(enviados).toBe(1);
|
||||
expect(sendMessageHandler).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("erro ao ler elegibilidade → NÃO envia, job volta pra 'pending' (fail-closed)", async () => {
|
||||
decidir.mockRejectedValue(new Error("db down"));
|
||||
const enviados = await enviarTextoFixoPendente(admin());
|
||||
expect(enviados).toBe(0);
|
||||
expect(sendMessageHandler).not.toHaveBeenCalled();
|
||||
expect(statusUpdates).toContain("pending");
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,8 @@ import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import { sendMessageHandler } from "@/app/api/v1/messages/_handler";
|
||||
import { ApiError } from "@/lib/api/types";
|
||||
import { ensureConversation, sessaoProntaParaEnvio } from "@/lib/automation/start-conversation";
|
||||
import { decidirElegibilidadeDaConversaViaSupabase } from "@/lib/ai/elegibilidade/consulta-supabase";
|
||||
import { ttlDaAutorizacaoMs } from "@/lib/ai/elegibilidade/gate";
|
||||
import { createSupabaseAdminClient, type FollowupJobRequest } from "@/lib/followup/engine";
|
||||
import type { EnrollmentRow } from "@/lib/followup/node-handlers";
|
||||
import { completeTurnForEnrollment, type TurnBridgeAdminClient } from "@/lib/followup/turn-bridge";
|
||||
@@ -84,6 +86,29 @@ export async function enviarTextoFixoPendente(
|
||||
contactId,
|
||||
sessionId,
|
||||
);
|
||||
|
||||
// GATE DE ELEGIBILIDADE — este envio inline BYPASSA `executarTurnoDoAgente`
|
||||
// (é o atalho "sem cron e sem agent-worker"), então precisa da checagem
|
||||
// por conta própria. Mesma regra pura do drain/turno. Canal 'open' → passa.
|
||||
// Bloqueio definitivo → o follow-up NÃO sai e o job vira `done`. Erro de
|
||||
// leitura → job volta pra `pending` (pode ser transitório) — fail-closed:
|
||||
// não envia sem confirmar.
|
||||
const elegib = await decidirElegibilidadeDaConversaViaSupabase(admin, {
|
||||
organizationId: job.organization_id as string,
|
||||
conversationId,
|
||||
agora: new Date(),
|
||||
ttlMs: ttlDaAutorizacaoMs(process.env),
|
||||
});
|
||||
if (elegib !== null && !elegib.permite) {
|
||||
logger.info("[followup] texto fixo não enviado — conversa não elegível para IA", {
|
||||
organization_id: job.organization_id,
|
||||
conversation_id: conversationId,
|
||||
motivo: elegib.motivo,
|
||||
});
|
||||
await admin.from("job_queue").update({ status: "done" }).eq("id", job.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
await sendMessageHandler(
|
||||
admin,
|
||||
{
|
||||
|
||||
@@ -143,7 +143,9 @@ export async function runSilenceSweep(deps: SilenceSweepDeps): Promise<SilenceSw
|
||||
return summary;
|
||||
}
|
||||
|
||||
type ContactEmbed = { tags: string[] | null; is_blocked: boolean | null } | null;
|
||||
type ContactEmbed =
|
||||
| { tags: string[] | null; is_blocked: boolean | null; ai_authorized_at: string | null }
|
||||
| null;
|
||||
|
||||
/** Production adapter: `SilenceSweepDb` sobre o client service-role real. */
|
||||
export function createSupabaseSilenceSweepDb(admin: SupabaseClient): SilenceSweepDb {
|
||||
@@ -191,15 +193,25 @@ export function createSupabaseSilenceSweepDb(admin: SupabaseClient): SilenceSwee
|
||||
// só faz sentido enquanto "o fluxo da conversa ainda está ativo".
|
||||
const { data, error } = await admin
|
||||
.from("conversations")
|
||||
.select("contact_id, last_inbound_at, contacts:contact_id(tags, is_blocked)")
|
||||
.select(
|
||||
"contact_id, last_inbound_at, contacts:contact_id(tags, is_blocked, ai_authorized_at), sessao:channel_session_id(metadata)",
|
||||
)
|
||||
.eq("organization_id", orgId)
|
||||
.not("last_inbound_at", "is", null)
|
||||
.not("status", "in", `(${CONVERSATION_TERMINAL_STATUSES.join(",")})`);
|
||||
if (error) throw new Error(error.message);
|
||||
|
||||
type Row = { contact_id: string; last_inbound_at: string; contacts: ContactEmbed };
|
||||
type Row = {
|
||||
contact_id: string;
|
||||
last_inbound_at: string;
|
||||
contacts: ContactEmbed;
|
||||
sessao: { metadata: Record<string, unknown> | null } | null;
|
||||
};
|
||||
const cutoff = new Date(cutoffIso).getTime();
|
||||
const latest = new Map<string, { at: number; tags: string[]; blocked: boolean }>();
|
||||
const latest = new Map<
|
||||
string,
|
||||
{ at: number; tags: string[]; blocked: boolean; gateAllowlist: boolean; autorizado: boolean }
|
||||
>();
|
||||
for (const row of (data ?? []) as unknown as Row[]) {
|
||||
const at = new Date(row.last_inbound_at).getTime();
|
||||
const prev = latest.get(row.contact_id);
|
||||
@@ -208,6 +220,8 @@ export function createSupabaseSilenceSweepDb(admin: SupabaseClient): SilenceSwee
|
||||
at,
|
||||
tags: row.contacts?.tags ?? [],
|
||||
blocked: row.contacts?.is_blocked ?? false,
|
||||
gateAllowlist: row.sessao?.metadata?.ai_gate === "allowlist",
|
||||
autorizado: row.contacts?.ai_authorized_at != null,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -215,6 +229,9 @@ export function createSupabaseSilenceSweepDb(admin: SupabaseClient): SilenceSwee
|
||||
const silentIds: string[] = [];
|
||||
for (const [contactId, v] of latest) {
|
||||
if (v.blocked) continue;
|
||||
// Gate `allowlist`: o follow-up automático também respeita a
|
||||
// elegibilidade — só entra contato que uma origem elegível autorizou.
|
||||
if (v.gateAllowlist && !v.autorizado) continue;
|
||||
if (v.at > cutoff) continue; // conversou depois do corte — não é silêncio
|
||||
if (segments.length > 0 && !segments.some((s) => v.tags.includes(s))) continue;
|
||||
silentIds.push(contactId);
|
||||
|
||||
+12
-4
@@ -14,6 +14,7 @@ import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
import { audit } from "@/lib/audit";
|
||||
import { sincronizarSaudeDaConexao } from "@/lib/channels/health";
|
||||
import { aplicarEfeitosPosEntrada } from "@/lib/channels/pos-entrada";
|
||||
import { pausarIaPorAtendimentoManual } from "@/lib/escalacao/atendimento-manual";
|
||||
import { acelerarPipelineDeEventos } from "@/lib/dev/kick-local-pipeline";
|
||||
import { canonicalPhoneBR } from "@/lib/channels/phone-variants";
|
||||
import { estamparAtribuicaoDoContato } from "@/lib/leads/atribuicao-de-anuncio";
|
||||
@@ -851,10 +852,17 @@ async function handleOutboundFromUserPhone(
|
||||
|
||||
await markConversation(admin, session.organization_id, conversationId, "outbound", previewFromMessage(p), now);
|
||||
|
||||
// Ver `silenciarBotPorRetomadaHumana` — um humano acabou de responder direto pelo
|
||||
// WhatsApp dele, fora do composer/IA; dá a ele uma janela curta de controle da
|
||||
// conversa sem precisar "assumir" formalmente no CRM.
|
||||
await silenciarBotPorRetomadaHumana(admin, session.organization_id, conversationId);
|
||||
// Uma PESSOA respondeu este cliente pelo celular, fora do composer/IA — a IA
|
||||
// para NESTA conversa para não responder junto, por uma janela que expira
|
||||
// sozinha (ver `PRAZO_DO_SILENCIO_MS`). NÃO mexe em `contacts.ai_authorized_at`
|
||||
// — a origem do lead é outro estado. Só as mensagens genuínas do celular
|
||||
// chegam aqui: o eco do nosso próprio envio (IA ou composer) já saiu no
|
||||
// `jaRegistrada` acima.
|
||||
await pausarIaPorAtendimentoManual(admin, {
|
||||
organizationId: session.organization_id,
|
||||
conversationId,
|
||||
canal: "waha",
|
||||
});
|
||||
|
||||
await audit({
|
||||
action: "message.sent",
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Unit do `scripts/ativar-gate-elegibilidade-ia.ts` — as peças puras
|
||||
* (`scripts/lib/gate-ativacao.ts`), com um `pg.Pool` falso.
|
||||
*
|
||||
* O comportamento com Postgres real (baseline + dados semeados) mora em
|
||||
* `tests/invariants/gate-ativacao.test.ts`.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
campanhaPerigosa,
|
||||
checkCampanhas,
|
||||
checkPlanoDeEscrita,
|
||||
checkSchema0203,
|
||||
checkDenyByDefault,
|
||||
type ConsultaPg,
|
||||
type CtxAtivacao,
|
||||
} from "./lib/gate-ativacao";
|
||||
import type { CampanhaWhatsapp } from "../lib/ai/elegibilidade/campanha";
|
||||
|
||||
const DIA = 86_400_000;
|
||||
|
||||
function camp(over: Partial<CampanhaWhatsapp> & { valor: string; tipo?: "contains" | "starts_with" }): CampanhaWhatsapp {
|
||||
return {
|
||||
id: over.id ?? "c1",
|
||||
match: { tipo: over.tipo ?? "contains", valor: over.valor },
|
||||
...(over.channel_session_id ? { channel_session_id: over.channel_session_id } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("campanhaPerigosa", () => {
|
||||
it("frase específica e longa → segura", () => {
|
||||
expect(campanhaPerigosa(camp({ valor: "Quero saber mais sobre marketing para incorporadoras" }))).toBeNull();
|
||||
});
|
||||
it("'contains' com frase curta → perigosa", () => {
|
||||
expect(campanhaPerigosa(camp({ valor: "bom dia" }))).toMatch(/casa conversa comum/);
|
||||
});
|
||||
it("'contains' com só 2 palavras → perigosa", () => {
|
||||
expect(campanhaPerigosa(camp({ valor: "quero orçamento" }))).toMatch(/palavra/);
|
||||
});
|
||||
it("'contains' longa mas toda de palavras genéricas → perigosa", () => {
|
||||
expect(campanhaPerigosa(camp({ valor: "quero saber mais informações sobre orçamento" }))).toMatch(/genéricas/);
|
||||
});
|
||||
it("'starts_with' com prefixo específico → segura", () => {
|
||||
expect(campanhaPerigosa(camp({ tipo: "starts_with", valor: "Campanha Meta Incorporadoras 2026" }))).toBeNull();
|
||||
});
|
||||
it("'starts_with' com prefixo curto → perigosa", () => {
|
||||
expect(campanhaPerigosa(camp({ tipo: "starts_with", valor: "oi tudo" }))).toMatch(/prefixo de/);
|
||||
});
|
||||
});
|
||||
|
||||
// ── pool falso ────────────────────────────────────────────────────────────
|
||||
|
||||
function poolFake(rotas: Array<{ casa: RegExp; rows: Array<Record<string, unknown>> }>): ConsultaPg {
|
||||
return {
|
||||
query: async (texto: string) => {
|
||||
for (const r of rotas) if (r.casa.test(texto)) return { rows: r.rows };
|
||||
return { rows: [] };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function ctx(pool: ConsultaPg, over: Partial<CtxAtivacao> = {}): CtxAtivacao {
|
||||
return {
|
||||
pool,
|
||||
organizationId: "org-1",
|
||||
channelSessionId: "chan-1",
|
||||
channelMetadata: {},
|
||||
raiz: "/nao-existe",
|
||||
ttlMs: 21 * DIA,
|
||||
alvoModo: "allowlist",
|
||||
rollback: false,
|
||||
opcoes: { permitirSemAgente: false, campanhasPerigosasOk: false, tamAmostra: 25 },
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("checkSchema0203", () => {
|
||||
it("colunas ausentes → FAIL", async () => {
|
||||
const r = await checkSchema0203(ctx(poolFake([{ casa: /information_schema/, rows: [] }])));
|
||||
expect(r.status).toBe("FAIL");
|
||||
expect(r.detalhe).toMatch(/migration 0203/);
|
||||
});
|
||||
it("colunas certas → PASS", async () => {
|
||||
const r = await checkSchema0203(
|
||||
ctx(
|
||||
poolFake([
|
||||
{
|
||||
casa: /information_schema/,
|
||||
rows: [
|
||||
{ column_name: "ai_authorized_at", data_type: "timestamp with time zone", is_nullable: "YES", column_default: null },
|
||||
{ column_name: "ai_authorized_reason", data_type: "text", is_nullable: "YES", column_default: null },
|
||||
],
|
||||
},
|
||||
]),
|
||||
),
|
||||
);
|
||||
expect(r.status).toBe("PASS");
|
||||
});
|
||||
it("coluna com NOT NULL ou default → FAIL", async () => {
|
||||
const r = await checkSchema0203(
|
||||
ctx(
|
||||
poolFake([
|
||||
{
|
||||
casa: /information_schema/,
|
||||
rows: [
|
||||
{ column_name: "ai_authorized_at", data_type: "timestamp with time zone", is_nullable: "NO", column_default: "now()" },
|
||||
{ column_name: "ai_authorized_reason", data_type: "text", is_nullable: "YES", column_default: null },
|
||||
],
|
||||
},
|
||||
]),
|
||||
),
|
||||
);
|
||||
expect(r.status).toBe("FAIL");
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkCampanhas", () => {
|
||||
const rota = (c: unknown) => poolFake([{ casa: /campanhas_whatsapp/, rows: [{ c }] }]);
|
||||
it("sem campanhas → INFO (não bloqueia)", async () => {
|
||||
expect((await checkCampanhas(ctx(rota(null)))).status).toBe("INFO");
|
||||
});
|
||||
it("campanha genérica → FAIL", async () => {
|
||||
const r = await checkCampanhas(ctx(rota([{ id: "x", match: { tipo: "contains", valor: "bom dia" } }])));
|
||||
expect(r.status).toBe("FAIL");
|
||||
});
|
||||
it("campanha genérica + flag → WARN", async () => {
|
||||
const r = await checkCampanhas(
|
||||
ctx(rota([{ id: "x", match: { tipo: "contains", valor: "bom dia" } }]), {
|
||||
opcoes: { permitirSemAgente: false, campanhasPerigosasOk: true, tamAmostra: 25 },
|
||||
}),
|
||||
);
|
||||
expect(r.status).toBe("WARN");
|
||||
});
|
||||
it("campanha genérica presa a OUTRO canal → não bloqueia este", async () => {
|
||||
// `channel_session_id` precisa de UUID válido e `match.valor` de >= 3 chars
|
||||
// (schema Zod) — senão a entrada é DESCARTADA e vira WARN de "inválida",
|
||||
// não o cenário que se quer testar. "bom dia" é genérica (perigosa) mas
|
||||
// presa a outro canal, então não deve bloquear ESTE.
|
||||
const r = await checkCampanhas(
|
||||
ctx(rota([{ id: "x", channel_session_id: "00000000-0000-4000-8000-0000000000ff", match: { tipo: "contains", valor: "bom dia" } }])),
|
||||
);
|
||||
expect(r.status).toBe("PASS");
|
||||
});
|
||||
it("campanha específica → PASS", async () => {
|
||||
const r = await checkCampanhas(
|
||||
ctx(rota([{ id: "incorp", match: { tipo: "contains", valor: "marketing para incorporadoras premium" } }])),
|
||||
);
|
||||
expect(r.status).toBe("PASS");
|
||||
});
|
||||
it("valor não-array → FAIL", async () => {
|
||||
expect((await checkCampanhas(ctx(rota({ foo: 1 })))).status).toBe("FAIL");
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkPlanoDeEscrita", () => {
|
||||
it("descreve a única escrita e afirma que NÃO toca contacts", () => {
|
||||
const r = checkPlanoDeEscrita(ctx(poolFake([]), { channelMetadata: {} }));
|
||||
expect(r.linhas?.join("\n")).toMatch(/update channel_sessions/);
|
||||
expect(r.linhas?.join("\n")).toMatch(/ZERO autorização em massa/);
|
||||
expect(r.linhas?.join("\n")).not.toMatch(/update contacts/);
|
||||
});
|
||||
it("gate já em allowlist → WARN (no-op)", () => {
|
||||
const r = checkPlanoDeEscrita(ctx(poolFake([]), { channelMetadata: { ai_gate: "allowlist" } }));
|
||||
expect(r.status).toBe("WARN");
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkDenyByDefault", () => {
|
||||
it("autorização rebelde (reason fora do vocabulário) → FAIL", async () => {
|
||||
const r = await checkDenyByDefault(ctx(poolFake([{ casa: /count\(\*\)/, rows: [{ n: 3 }] }])));
|
||||
expect(r.status).toBe("FAIL");
|
||||
expect(r.detalhe).toMatch(/fora do produto/);
|
||||
});
|
||||
it("contato antigo sem autorização → não autorizado, PASS", async () => {
|
||||
const r = await checkDenyByDefault(
|
||||
ctx(
|
||||
poolFake([
|
||||
{ casa: /count\(\*\)/, rows: [{ n: 0 }] },
|
||||
{
|
||||
casa: /order by ct\.created_at/,
|
||||
rows: [
|
||||
{ id: "velho", created_at: "2024-01-01T00:00:00Z", ai_authorized_at: null, force_human: false, assignee_kind: "ai", bot_silenced_until: null },
|
||||
],
|
||||
},
|
||||
]),
|
||||
),
|
||||
);
|
||||
expect(r.status).toBe("PASS");
|
||||
expect(r.linhas?.join("\n")).toMatch(/contato velho.*não autorizado \(sem_autorizacao\)/);
|
||||
expect(r.linhas?.join("\n")).not.toMatch(/AUTORIZADO\?!/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* ATIVA (ou desliga) o gate de elegibilidade da IA num canal de WhatsApp —
|
||||
* `channel_sessions.metadata.ai_gate = 'allowlist'`.
|
||||
*
|
||||
* ─── Por que este script existe ────────────────────────────────────────────
|
||||
*
|
||||
* O gate ainda não tem tela (dívida declarada em J20 do user-journey-map). Até
|
||||
* ela existir, liga-se por aqui — como o `roteamento_de_formulario`. Este NÃO é
|
||||
* um `UPDATE` seco: roda uma bateria de preflights ANTES de deixar escrever, e a
|
||||
* escrita só acontece com `--apply` explícito.
|
||||
*
|
||||
* ─── O que ele garante ─────────────────────────────────────────────────────
|
||||
*
|
||||
* - DRY-RUN é o PADRÃO; `--apply` é obrigatório para qualquer escrita;
|
||||
* - a ÚNICA escrita possível é UMA linha de `channel_sessions.metadata`;
|
||||
* - NUNCA escreve em `contacts` — nada de autorização em massa. Um contato só
|
||||
* fica elegível pelas quatro origens do produto (webhook do Respondi, match
|
||||
* de campanha, ação `send_ai_message`, retomada manual pela tela);
|
||||
* - fail-closed: qualquer preflight FAIL aborta o `--apply`;
|
||||
* - `--rollback` desliga o gate (volta para `'open'`), com os mesmos preflights.
|
||||
*
|
||||
* ─── Uso ──────────────────────────────────────────────────────────────────
|
||||
*
|
||||
* # listar os canais de uma organização e o estado do gate de cada um
|
||||
* tsx --env-file=.env scripts/ativar-gate-elegibilidade-ia.ts --org <org_id>
|
||||
*
|
||||
* # DRY-RUN (padrão) — roda todos os preflights, escreve NADA
|
||||
* tsx --env-file=.env scripts/ativar-gate-elegibilidade-ia.ts --channel <id|telefone|nome>
|
||||
*
|
||||
* # APLICAR — liga o gate (só se nenhum preflight for FAIL)
|
||||
* tsx --env-file=.env scripts/ativar-gate-elegibilidade-ia.ts --channel <...> --apply
|
||||
*
|
||||
* # ROLLBACK — dry-run de desligar / aplicar o desligamento
|
||||
* tsx --env-file=.env scripts/ativar-gate-elegibilidade-ia.ts --channel <...> --rollback
|
||||
* tsx --env-file=.env scripts/ativar-gate-elegibilidade-ia.ts --channel <...> --rollback --apply
|
||||
*
|
||||
* Flags de escape (cada uma afrouxa UM preflight — use com consciência):
|
||||
* --permitir-sem-agente segue mesmo sem agente publicado / roteador no canal
|
||||
* --campanhas-perigosas-ok segue mesmo com campanha de match genérico
|
||||
* --amostra <n> tamanho da amostra impressa (default 25)
|
||||
*
|
||||
* Conexão: `SUPABASE_DB_URL` (Postgres direto — o script conta linhas de toda a
|
||||
* organização, o que a anon/authenticated key não alcança).
|
||||
*/
|
||||
import pg from "pg";
|
||||
|
||||
import { carregarEnvLocal, credenciaisSupabaseDeTeste, anunciarDestino } from "./lib/env-de-teste";
|
||||
import { montarPreflights, type CtxAtivacao, type Resultado, type Status } from "./lib/gate-ativacao";
|
||||
import { lerModoDoGate, ttlDaAutorizacaoMs } from "../lib/ai/elegibilidade/gate";
|
||||
|
||||
// ─── Args ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const ARGV = process.argv.slice(2);
|
||||
const flag = (n: string) => ARGV.includes(`--${n}`);
|
||||
const opcao = (n: string): string | undefined => {
|
||||
const i = ARGV.indexOf(`--${n}`);
|
||||
return i >= 0 && ARGV[i + 1] && !ARGV[i + 1]!.startsWith("--") ? ARGV[i + 1] : undefined;
|
||||
};
|
||||
|
||||
const MODO_ORG = opcao("org");
|
||||
const CANAL_REF = opcao("channel");
|
||||
const APLICAR = flag("apply");
|
||||
const ROLLBACK = flag("rollback");
|
||||
const ALVO_MODO: "allowlist" | "open" = ROLLBACK ? "open" : "allowlist";
|
||||
const ICONE: Record<Status, string> = { PASS: "✅", WARN: "⚠️ ", FAIL: "❌", INFO: "ℹ️ " };
|
||||
|
||||
// ─── Conexão ──────────────────────────────────────────────────────────────
|
||||
|
||||
const env = carregarEnvLocal();
|
||||
const creds = credenciaisSupabaseDeTeste();
|
||||
anunciarDestino("ativar-gate-elegibilidade-ia", creds);
|
||||
|
||||
const DB_URL = env.SUPABASE_DB_URL ?? creds.dbUrl ?? "";
|
||||
if (DB_URL === "") {
|
||||
console.error("\n❌ SUPABASE_DB_URL ausente — este script fala direto com o Postgres.\n");
|
||||
process.exit(2);
|
||||
}
|
||||
const pool = new pg.Pool({ connectionString: DB_URL, max: 3 });
|
||||
|
||||
const RAIZ = process.cwd();
|
||||
const TTL_MS = ttlDaAutorizacaoMs(process.env);
|
||||
|
||||
// ─── Descoberta de canal ──────────────────────────────────────────────────
|
||||
|
||||
interface Canal {
|
||||
id: string;
|
||||
organization_id: string;
|
||||
org_nome: string;
|
||||
waha_session_name: string;
|
||||
phone_number: string | null;
|
||||
display_name: string | null;
|
||||
status: string;
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function listarCanaisDaOrg(orgId: string): Promise<void> {
|
||||
const { rows } = await pool.query(
|
||||
`select cs.id, cs.waha_session_name, cs.phone_number, cs.display_name, cs.status,
|
||||
cs.metadata->>'ai_gate' as ai_gate, coalesce(o.display_name, o.legal_name, o.slug) as org_nome
|
||||
from channel_sessions cs join organizations o on o.id = cs.organization_id
|
||||
where cs.organization_id = $1 order by cs.created_at`,
|
||||
[orgId],
|
||||
);
|
||||
if (rows.length === 0) {
|
||||
console.info(`\nNenhum canal para a organização ${orgId}.\n`);
|
||||
return;
|
||||
}
|
||||
console.info(`\nCanais da organização "${rows[0].org_nome as string}" (${orgId}):\n`);
|
||||
for (const r of rows) {
|
||||
const modo = lerModoDoGate(r.ai_gate);
|
||||
console.info(
|
||||
` ${r.id as string}\n` +
|
||||
` sessão: ${r.waha_session_name as string} · número: ${(r.phone_number as string) ?? "(sem)"} · nome: ${(r.display_name as string) ?? "(sem)"}\n` +
|
||||
` status: ${r.status as string} · gate: ${modo === "allowlist" ? "🔒 allowlist" : "🔓 open (padrão)"}\n`,
|
||||
);
|
||||
}
|
||||
console.info("Inspecionar um: --channel <id> (dry-run). Ligar: --channel <id> --apply.\n");
|
||||
}
|
||||
|
||||
async function resolverCanal(ref: string): Promise<Canal> {
|
||||
const ehUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(ref);
|
||||
const digitos = ref.replace(/\D/g, "");
|
||||
const { rows } = await pool.query(
|
||||
`select cs.id, cs.organization_id, coalesce(o.display_name, o.legal_name, o.slug) as org_nome, cs.waha_session_name,
|
||||
cs.phone_number, cs.display_name, cs.status, cs.metadata
|
||||
from channel_sessions cs join organizations o on o.id = cs.organization_id
|
||||
where ($1::boolean and cs.id = $2::uuid)
|
||||
or (length($3) >= 8 and regexp_replace(coalesce(cs.phone_number,''), '\\D', '', 'g') like '%' || $3)
|
||||
or cs.waha_session_name = $4 or cs.display_name = $4`,
|
||||
[ehUuid, ehUuid ? ref : "00000000-0000-0000-0000-000000000000", digitos, ref],
|
||||
);
|
||||
if (rows.length === 0) throw new Error(`Nenhum canal casa "${ref}" (id, telefone, waha_session_name ou display_name).`);
|
||||
if (rows.length > 1) {
|
||||
throw new Error(
|
||||
`"${ref}" casa ${rows.length} canais: ${rows.map((r) => `${r.id as string} (${(r.display_name as string) ?? (r.waha_session_name as string)})`).join(", ")}. Use o id.`,
|
||||
);
|
||||
}
|
||||
const r = rows[0];
|
||||
return {
|
||||
id: r.id as string,
|
||||
organization_id: r.organization_id as string,
|
||||
org_nome: r.org_nome as string,
|
||||
waha_session_name: r.waha_session_name as string,
|
||||
phone_number: (r.phone_number as string | null) ?? null,
|
||||
display_name: (r.display_name as string | null) ?? null,
|
||||
status: r.status as string,
|
||||
metadata: (r.metadata as Record<string, unknown>) ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Escrita (a ÚNICA) ────────────────────────────────────────────────────
|
||||
|
||||
async function aplicarEscrita(canal: Canal): Promise<void> {
|
||||
const cliente = await pool.connect();
|
||||
try {
|
||||
await cliente.query("begin");
|
||||
const antes = await cliente.query(
|
||||
`select metadata->>'ai_gate' as g from channel_sessions where id = $1 and organization_id = $2 for update`,
|
||||
[canal.id, canal.organization_id],
|
||||
);
|
||||
if (antes.rows.length === 0) throw new Error("o canal sumiu entre o preflight e a escrita");
|
||||
if (lerModoDoGate(antes.rows[0].g) === ALVO_MODO) {
|
||||
await cliente.query("rollback");
|
||||
console.info(`\nℹ️ o gate JÁ estava em "${ALVO_MODO}" — nada a escrever.\n`);
|
||||
return;
|
||||
}
|
||||
const res = await cliente.query(
|
||||
`update channel_sessions
|
||||
set metadata = jsonb_set(coalesce(metadata, '{}'::jsonb), '{ai_gate}', $3::jsonb), updated_at = now()
|
||||
where id = $1 and organization_id = $2`,
|
||||
[canal.id, canal.organization_id, JSON.stringify(ALVO_MODO)],
|
||||
);
|
||||
if (res.rowCount !== 1) throw new Error(`update afetou ${res.rowCount} linha(s), esperado 1 — revertendo`);
|
||||
const depois = await cliente.query(
|
||||
`select metadata->>'ai_gate' as g from channel_sessions where id = $1 and organization_id = $2`,
|
||||
[canal.id, canal.organization_id],
|
||||
);
|
||||
if (lerModoDoGate(depois.rows[0].g) !== ALVO_MODO) throw new Error("leitura pós-escrita não bate — revertendo");
|
||||
await cliente.query("commit");
|
||||
console.info(`\n✅ ESCRITO. channel_sessions.metadata.ai_gate: ${JSON.stringify(antes.rows[0].g ?? null)} → "${ALVO_MODO}"\n`);
|
||||
} catch (e) {
|
||||
await cliente.query("rollback").catch(() => {});
|
||||
throw e;
|
||||
} finally {
|
||||
cliente.release();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Main ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async function main(): Promise<number> {
|
||||
console.info(`\n${"═".repeat(78)}`);
|
||||
console.info(
|
||||
` GATE DE ELEGIBILIDADE DA IA — ${ROLLBACK ? "ROLLBACK (desligar)" : "ativação"} · ` +
|
||||
`${APLICAR ? "MODO --apply (ESCREVE)" : "DRY-RUN (não escreve nada)"}`,
|
||||
);
|
||||
console.info(`${"═".repeat(78)}\n`);
|
||||
|
||||
if (MODO_ORG) {
|
||||
await listarCanaisDaOrg(MODO_ORG);
|
||||
return 0;
|
||||
}
|
||||
if (!CANAL_REF) {
|
||||
console.error("Faltou --channel <id|telefone|nome> (ou --org <id> para listar).\n");
|
||||
return 2;
|
||||
}
|
||||
|
||||
const canal = await resolverCanal(CANAL_REF);
|
||||
console.info(
|
||||
`Canal: ${canal.id}\n` +
|
||||
` org: "${canal.org_nome}" (${canal.organization_id})\n` +
|
||||
` sessão: ${canal.waha_session_name} · número: ${canal.phone_number ?? "(sem)"} · status: ${canal.status}\n` +
|
||||
` gate agora: ${lerModoDoGate(canal.metadata.ai_gate)}\n` +
|
||||
` TTL de autorização: ${Math.round(TTL_MS / 86_400_000)} dias (AI_ALLOWLIST_TTL_DAYS)\n`,
|
||||
);
|
||||
|
||||
const ctx: CtxAtivacao = {
|
||||
pool,
|
||||
organizationId: canal.organization_id,
|
||||
channelSessionId: canal.id,
|
||||
channelMetadata: canal.metadata,
|
||||
raiz: RAIZ,
|
||||
ttlMs: TTL_MS,
|
||||
alvoModo: ALVO_MODO,
|
||||
rollback: ROLLBACK,
|
||||
opcoes: {
|
||||
permitirSemAgente: flag("permitir-sem-agente"),
|
||||
campanhasPerigosasOk: flag("campanhas-perigosas-ok"),
|
||||
tamAmostra: Number(opcao("amostra") ?? "25") || 25,
|
||||
},
|
||||
};
|
||||
|
||||
let houveFail = false;
|
||||
let houveWarn = false;
|
||||
for (const { nome, run } of montarPreflights(ctx)) {
|
||||
let r: Resultado;
|
||||
try {
|
||||
r = await run();
|
||||
} catch (e) {
|
||||
r = { status: "FAIL", detalhe: `o preflight lançou: ${e instanceof Error ? e.message : String(e)}` };
|
||||
}
|
||||
if (r.status === "FAIL") houveFail = true;
|
||||
if (r.status === "WARN") houveWarn = true;
|
||||
console.info(`${ICONE[r.status]} ${nome}`);
|
||||
console.info(` ${r.detalhe}`);
|
||||
for (const l of r.linhas ?? []) console.info(` ${l}`);
|
||||
console.info("");
|
||||
}
|
||||
|
||||
console.info("─".repeat(78));
|
||||
const cmdApply = `tsx --env-file=.env scripts/ativar-gate-elegibilidade-ia.ts --channel ${canal.id}${ROLLBACK ? " --rollback" : ""} --apply`;
|
||||
const cmdRollback = `tsx --env-file=.env scripts/ativar-gate-elegibilidade-ia.ts --channel ${canal.id} --rollback --apply`;
|
||||
|
||||
if (!APLICAR) {
|
||||
console.info("DRY-RUN — nada foi escrito.");
|
||||
if (houveFail) {
|
||||
console.info("\n❌ Há preflight(s) FAIL — o --apply seria RECUSADO. Resolva-os primeiro.\n");
|
||||
return 1;
|
||||
}
|
||||
console.info(
|
||||
`\n${houveWarn ? "⚠️ Há WARN(s) — leia acima antes de aplicar." : "Todos os preflights passaram."}\n` +
|
||||
`Para APLICAR:\n ${cmdApply}\n` +
|
||||
`Para DESLIGAR depois:\n ${cmdRollback}\n`,
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (houveFail) {
|
||||
console.info("\n❌ --apply RECUSADO: há preflight(s) FAIL acima. Nada foi escrito.\n");
|
||||
return 1;
|
||||
}
|
||||
console.info(`Preflights ok. Aplicando a ÚNICA escrita (metadata.ai_gate = "${ALVO_MODO}")...`);
|
||||
await aplicarEscrita(canal);
|
||||
console.info(
|
||||
`Rollback a qualquer momento:\n ${
|
||||
ROLLBACK
|
||||
? `tsx --env-file=.env scripts/ativar-gate-elegibilidade-ia.ts --channel ${canal.id} --apply`
|
||||
: cmdRollback
|
||||
}\n`,
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
main()
|
||||
.then(async (code) => {
|
||||
await pool.end();
|
||||
process.exit(code);
|
||||
})
|
||||
.catch(async (e) => {
|
||||
console.error("\n💥", e instanceof Error ? e.message : e);
|
||||
await pool.end().catch(() => {});
|
||||
process.exit(3);
|
||||
});
|
||||
@@ -0,0 +1,460 @@
|
||||
/**
|
||||
* Helpers de SQL cru para os E2E do GATE DE ELEGIBILIDADE DA IA (J20):
|
||||
*
|
||||
* tests/e2e/j20-elegibilidade-respondi.spec.ts (J20.6)
|
||||
* tests/e2e/j20-elegibilidade-followup.spec.ts (J20.12)
|
||||
* tests/e2e/j20-elegibilidade-atendimento-manual.spec.ts (J20.18)
|
||||
*
|
||||
* Mesma doutrina de `scripts/e2e-followup-journey-helpers.ts`: cobre só o que a
|
||||
* API pública genuinamente não expõe. Aqui são três coisas:
|
||||
*
|
||||
* 1. Rodar UM tick do drain do agent-engine
|
||||
* (`lib/agent-engine/edge/crm/drain.ts`, `drainTick`) — o consumidor de
|
||||
* `ai_agent.dispatch_requested` que carrega o gate. Em produção ele roda
|
||||
* no `workers/agent-worker` (processo 24/7); a suíte E2E não sobe worker,
|
||||
* então o tick é chamado aqui, pela MESMA função, contra `SUPABASE_DB_URL`.
|
||||
* 2. Ler `job_queue` / `event_log` / `contacts` / `conversations` /
|
||||
* `followup_enrollments` direto — a prova de "a IA foi (ou não) liberada"
|
||||
* é uma linha de fila que nasceu (ou um evento que virou `done` sem job),
|
||||
* e nada disso tem rota REST.
|
||||
* 3. Semear com precisão os dois estados de partida que o gate distingue:
|
||||
* contato AUTORIZADO (origem elegível carimbou `contacts.ai_authorized_at`)
|
||||
* e contato NÃO autorizado — sem depender de rodar o webhook do Respondi
|
||||
* quando o caso sob teste é outro (J20.12 e J20.18).
|
||||
*
|
||||
* Conecta em Postgres DIRETO via `SUPABASE_DB_URL` (mesmo padrão de
|
||||
* `lib/agent-engine/db/pool.ts` e de `e2e-followup-journey-helpers.ts`) — o
|
||||
* `drainTick` só existe em sabor `pg.Pool`.
|
||||
*
|
||||
* CLI de subcomandos, 1 processo por chamada: cada subcomando imprime 1 linha
|
||||
* de JSON em stdout que a spec faz `JSON.parse`.
|
||||
*
|
||||
* Run: npx tsx scripts/e2e-elegibilidade-helpers.ts <comando> [args...]
|
||||
*/
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import pg from "pg";
|
||||
|
||||
import { drainTick } from "@/lib/agent-engine/edge/crm/drain";
|
||||
import { createLogger } from "@/lib/agent-engine/obs/logger";
|
||||
import { carregarEnvLocal } from "../scripts/lib/env-de-teste";
|
||||
|
||||
const env = carregarEnvLocal();
|
||||
|
||||
const DB_URL = env.SUPABASE_DB_URL;
|
||||
if (!DB_URL) throw new Error("Falta SUPABASE_DB_URL no ambiente (.env.e2e / .env.local)");
|
||||
|
||||
const CREDS_PATH = path.join(process.cwd(), ".e2e-creds.json");
|
||||
|
||||
interface Creds {
|
||||
org_id: string;
|
||||
elegibilidade?: {
|
||||
channel_session_id: string;
|
||||
waha_path_token: string;
|
||||
credential_id: string;
|
||||
pipeline_id: string;
|
||||
stage_id: string;
|
||||
webhook_source_id: string;
|
||||
webhook_source_token: string;
|
||||
};
|
||||
}
|
||||
|
||||
function loadCreds(): Creds {
|
||||
return JSON.parse(fs.readFileSync(CREDS_PATH, "utf8")) as Creds;
|
||||
}
|
||||
|
||||
function out(value: unknown): void {
|
||||
console.info(JSON.stringify(value));
|
||||
}
|
||||
|
||||
/** E.164 único por chamada — `contacts_phone_e164_format` exige `^\+\d{8,15}$`. */
|
||||
function telefoneUnico(): string {
|
||||
return `+55119${String(Date.now()).slice(-8)}`;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const [, , cmd, ...args] = process.argv;
|
||||
const pool = new pg.Pool({ connectionString: DB_URL, max: 3 });
|
||||
|
||||
try {
|
||||
switch (cmd) {
|
||||
// ── drain-once ──────────────────────────────────────────────────────────
|
||||
// UM tick do drain do agent-engine (consumidor de
|
||||
// `ai_agent.dispatch_requested`). `debounceMs: 0` para o job de turno,
|
||||
// quando criado, nascer sem `run_after` no futuro — a spec o vê já.
|
||||
case "drain-once": {
|
||||
const log = createLogger();
|
||||
const drained = await drainTick(pool, {
|
||||
batchSize: 50,
|
||||
intervalMs: 1_000,
|
||||
idleIntervalMs: 5_000,
|
||||
debounceMs: 0,
|
||||
reapTimeoutMs: 60_000,
|
||||
}, log);
|
||||
out({ drained });
|
||||
break;
|
||||
}
|
||||
|
||||
// ── job-inbound-turn <contactId> ────────────────────────────────────────
|
||||
// A linha de `job_queue` que PROVA que o drain liberou a IA para o turno.
|
||||
// `null` = nenhum job (a IA NÃO foi liberada).
|
||||
case "job-inbound-turn": {
|
||||
const contactId = args[0];
|
||||
if (!contactId) throw new Error("contactId obrigatório");
|
||||
const { rows } = await pool.query(
|
||||
`select id, kind, status, source_event_id
|
||||
from job_queue
|
||||
where contact_id = $1 and kind = 'inbound_turn'
|
||||
order by created_at desc limit 1`,
|
||||
[contactId],
|
||||
);
|
||||
out(rows[0] ?? null);
|
||||
break;
|
||||
}
|
||||
|
||||
// ── dispatch-event <contactId> ─────────────────────────────────────────
|
||||
// O estado do `ai_agent.dispatch_requested` deste contato após o drain:
|
||||
// `done` sem job = o gate barrou (turno pulado, sem gasto).
|
||||
case "dispatch-event": {
|
||||
const contactId = args[0];
|
||||
if (!contactId) throw new Error("contactId obrigatório");
|
||||
const { rows } = await pool.query(
|
||||
`select e.id, e.status
|
||||
from event_log e
|
||||
where e.event_type = 'ai_agent.dispatch_requested'
|
||||
and e.payload->>'contact_id' = $1
|
||||
order by e.created_at desc limit 1`,
|
||||
[contactId],
|
||||
);
|
||||
out(rows[0] ?? null);
|
||||
break;
|
||||
}
|
||||
|
||||
// ── set-authorized <contactId> [reason] ──────────────────────────────
|
||||
// Carimba `contacts.ai_authorized_at = now()` como uma origem elegível
|
||||
// faria (J20.18 quer partir de uma conversa JÁ autorizada). Não passa
|
||||
// pelo webhook do Respondi de propósito — o caso sob teste é outro.
|
||||
case "set-authorized": {
|
||||
const contactId = args[0];
|
||||
const reason = args[1] ?? "respondi:e2e-form:e2e-sub";
|
||||
if (!contactId) throw new Error("contactId obrigatório");
|
||||
const { rowCount } = await pool.query(
|
||||
`update contacts set ai_authorized_at = now(), ai_authorized_reason = $2 where id = $1`,
|
||||
[contactId, reason],
|
||||
);
|
||||
out({ ok: rowCount === 1 });
|
||||
break;
|
||||
}
|
||||
|
||||
// ── conversation-for-contact <contactId> ─────────────────────────────
|
||||
case "conversation-for-contact": {
|
||||
const contactId = args[0];
|
||||
if (!contactId) throw new Error("contactId obrigatório");
|
||||
const { rows } = await pool.query(
|
||||
`select id, status, bot_silenced_until::text as bot_silenced_until
|
||||
from conversations where contact_id = $1
|
||||
order by created_at desc limit 1`,
|
||||
[contactId],
|
||||
);
|
||||
out(rows[0] ?? null);
|
||||
break;
|
||||
}
|
||||
|
||||
// ── contact-authorization <contactId> ─────────────────────────────────
|
||||
case "contact-authorization": {
|
||||
const contactId = args[0];
|
||||
if (!contactId) throw new Error("contactId obrigatório");
|
||||
const { rows } = await pool.query(
|
||||
`select id, ai_authorized_at, ai_authorized_reason from contacts where id = $1`,
|
||||
[contactId],
|
||||
);
|
||||
out(rows[0] ?? null);
|
||||
break;
|
||||
}
|
||||
|
||||
// ── conversation-silence <conversationId> ─────────────────────────────
|
||||
// O valor CRU de `bot_silenced_until` (`infinity` é literal, não data) +
|
||||
// o rastro de handoff.
|
||||
case "conversation-silence": {
|
||||
const conversationId = args[0];
|
||||
if (!conversationId) throw new Error("conversationId obrigatório");
|
||||
const { rows } = await pool.query(
|
||||
`select id, bot_silenced_until::text as bot_silenced_until,
|
||||
last_handoff_at::text as last_handoff_at, last_handoff_reason
|
||||
from conversations where id = $1`,
|
||||
[conversationId],
|
||||
);
|
||||
out(rows[0] ?? null);
|
||||
break;
|
||||
}
|
||||
|
||||
// ── find-contact-by-phone <phoneDigits> ───────────────────────────────
|
||||
// O contato que o webhook do Respondi criou (a spec passa os dígitos que
|
||||
// mandou no payload). `phone_number` é normalizado para E.164 na ingestão.
|
||||
case "find-contact-by-phone": {
|
||||
const digits = (args[0] ?? "").replace(/\D/g, "");
|
||||
if (digits.length < 8) throw new Error("phoneDigits inválido");
|
||||
const creds = loadCreds();
|
||||
const { rows } = await pool.query(
|
||||
`select id, phone_number, ai_authorized_at, ai_authorized_reason
|
||||
from contacts
|
||||
where organization_id = $1
|
||||
and regexp_replace(coalesce(phone_number, ''), '\\D', '', 'g') like '%' || $2
|
||||
order by created_at desc limit 1`,
|
||||
[creds.org_id, digits],
|
||||
);
|
||||
out(rows[0] ?? null);
|
||||
break;
|
||||
}
|
||||
|
||||
// ── seed-silent-contact <autorizado:0|1> <thresholdMinutes> ───────────
|
||||
// Igual a seed-conversa mas com `last_inbound_at` mais VELHO que o
|
||||
// threshold do gatilho de silêncio — o estado que a varredura de
|
||||
// follow-up procura. (J20.12.)
|
||||
case "seed-silent-contact": {
|
||||
const autorizado = args[0] === "1";
|
||||
const thresholdMinutes = Number(args[1] ?? "5");
|
||||
if (!Number.isFinite(thresholdMinutes)) throw new Error("thresholdMinutes inválido");
|
||||
const creds = loadCreds();
|
||||
const fix = creds.elegibilidade;
|
||||
if (!fix) throw new Error("bloco `elegibilidade` ausente — rode scripts/seed-e2e-elegibilidade.ts");
|
||||
|
||||
const phone = telefoneUnico();
|
||||
const nome = `Silêncio Elegibilidade ${autorizado ? "autorizado" : "sem-autorizacao"} ${Date.now()}`;
|
||||
const { rows: cRows } = await pool.query<{ id: string }>(
|
||||
`insert into contacts (organization_id, display_name, phone_number${autorizado ? ", ai_authorized_at, ai_authorized_reason" : ""})
|
||||
values ($1, $2, $3${autorizado ? ", now(), 'respondi:e2e-form:e2e-sub'" : ""})
|
||||
returning id`,
|
||||
[creds.org_id, nome, phone],
|
||||
);
|
||||
const contactId = cRows[0]!.id;
|
||||
const velho = new Date(Date.now() - (thresholdMinutes + 5) * 60_000).toISOString();
|
||||
const { rows: convRows } = await pool.query<{ id: string }>(
|
||||
`insert into conversations
|
||||
(organization_id, contact_id, channel_session_id, status,
|
||||
last_message_preview, last_message_at, last_inbound_at)
|
||||
values ($1, $2, $3, 'open', 'Oi, tudo bem?', $4, $4)
|
||||
returning id`,
|
||||
[creds.org_id, contactId, fix.channel_session_id, velho],
|
||||
);
|
||||
out({ contactId, conversationId: convRows[0]!.id, phone });
|
||||
break;
|
||||
}
|
||||
|
||||
// ── enrollment-for-contact <contactId> ────────────────────────────────
|
||||
// A linha de `followup_enrollments` (ou `null`). J20.12: o autorizado TEM,
|
||||
// o não autorizado NÃO.
|
||||
case "enrollment-for-contact": {
|
||||
const contactId = args[0];
|
||||
if (!contactId) throw new Error("contactId obrigatório");
|
||||
const { rows } = await pool.query(
|
||||
`select id, pointer_id, status, current_node_id
|
||||
from followup_enrollments where contact_id = $1
|
||||
order by started_at desc limit 1`,
|
||||
[contactId],
|
||||
);
|
||||
out(rows[0] ?? null);
|
||||
break;
|
||||
}
|
||||
|
||||
// ── cleanup-contact <contactId> ──────────────────────────────────────
|
||||
// Ordem que respeita as FKs do baseline. Não deixa lixo no dev DB
|
||||
// compartilhado.
|
||||
case "cleanup-contact": {
|
||||
const contactId = args[0];
|
||||
if (!contactId) throw new Error("contactId obrigatório");
|
||||
await pool.query(
|
||||
`delete from job_queue where contact_id = $1`,
|
||||
[contactId],
|
||||
);
|
||||
await pool.query(
|
||||
`delete from followup_enrollment_events where enrollment_id in
|
||||
(select id from followup_enrollments where contact_id = $1)`,
|
||||
[contactId],
|
||||
);
|
||||
await pool.query(`delete from followup_enrollments where contact_id = $1`, [contactId]);
|
||||
await pool.query(
|
||||
`delete from messages where conversation_id in
|
||||
(select id from conversations where contact_id = $1)`,
|
||||
[contactId],
|
||||
);
|
||||
await pool.query(
|
||||
`delete from event_log where payload->>'contact_id' = $1`,
|
||||
[contactId],
|
||||
);
|
||||
await pool.query(`delete from conversations where contact_id = $1`, [contactId]);
|
||||
// Um lead pode ter nascido pelo webhook do Respondi (J20.6). Timeline
|
||||
// (`crm_lead_activities`) sai no cascade do lead.
|
||||
await pool.query(`delete from crm_leads where contact_id = $1`, [contactId]);
|
||||
await pool.query(`delete from contacts where id = $1`, [contactId]);
|
||||
out({ ok: true });
|
||||
break;
|
||||
}
|
||||
|
||||
// ── cleanup-flow <pointerId> ─────────────────────────────────────────
|
||||
// Desativa o pointer e apaga os enrollments que ticks intermediários
|
||||
// possam ter criado (a varredura de silêncio é cross-contato — pode ter
|
||||
// pego contato silencioso real do dev DB). Mesmo cuidado de
|
||||
// e2e-followup-journey-helpers.
|
||||
case "cleanup-flow": {
|
||||
const pointerId = args[0];
|
||||
if (!pointerId) throw new Error("pointerId obrigatório");
|
||||
await pool.query(
|
||||
`update followup_flow_pointers set status = 'disabled' where id = $1`,
|
||||
[pointerId],
|
||||
);
|
||||
await pool.query(
|
||||
`delete from followup_enrollment_events where enrollment_id in
|
||||
(select id from followup_enrollments where pointer_id = $1)`,
|
||||
[pointerId],
|
||||
);
|
||||
const { rowCount } = await pool.query(
|
||||
`delete from followup_enrollments where pointer_id = $1`,
|
||||
[pointerId],
|
||||
);
|
||||
out({ ok: true, enrollmentsRemovidos: rowCount });
|
||||
break;
|
||||
}
|
||||
|
||||
// ── publish-agent [pointerId] ───────────────────────────────────────
|
||||
// Insere `ai_agents` (mcp_agent) + `ai_agent_versions` PUBLICADA, ligada
|
||||
// ao canal do gate. É SETUP, não o que está sob teste: o `POST
|
||||
// /api/v1/ai/agents` exige role `admin` (MFA), e o agente publicado só
|
||||
// precisa EXISTIR para o drain não pular por "nenhum agente publicado" e
|
||||
// para o gate de follow-up abrir. Idempotente por (org, name).
|
||||
// - sem pointerId: `followup` fica desligado (J20.6)
|
||||
// - com pointerId: `followup = {enabled:true, flow_pointer_ids:[id]}` (J20.12)
|
||||
//
|
||||
// O NOME depende de ter pointerId: as specs J20 rodam em paralelo contra o
|
||||
// mesmo banco e o `followup` é coluna compartilhada — se J20.6 e J20.12
|
||||
// dividissem a linha, o publish-agent de um sobrescreveria o `followup` do
|
||||
// outro (J20.12 quer `enabled:true`; J20.6, `false`). Agentes separados não
|
||||
// colidem, e `resolveAgentForAutomaticTrigger` do sweep escolhe justamente
|
||||
// o que ARMA o pointer, então o agente "sem followup" nunca é candidato.
|
||||
case "publish-agent": {
|
||||
const pointerId = args[0] ?? null;
|
||||
const creds = loadCreds();
|
||||
const fix = creds.elegibilidade;
|
||||
if (!fix) throw new Error("bloco `elegibilidade` ausente — rode scripts/seed-e2e-elegibilidade.ts");
|
||||
const nome = pointerId
|
||||
? "E2E Elegibilidade — agente publicado (followup)"
|
||||
: "E2E Elegibilidade — agente publicado";
|
||||
const followup = pointerId
|
||||
? JSON.stringify({ enabled: true, flow_pointer_ids: [pointerId] })
|
||||
: JSON.stringify({ enabled: false, flow_pointer_ids: [] });
|
||||
|
||||
const cli = await pool.connect();
|
||||
try {
|
||||
await cli.query("begin");
|
||||
// As specs J20 rodam em paralelo contra o MESMO banco e mais de uma
|
||||
// chama `publish-agent` para o MESMO agente (mesmo `name`). Sem
|
||||
// serializar, dois workers leem `published_version_id` nulo ao mesmo
|
||||
// tempo, os dois entram no ramo de INSERT e o segundo colide (ou pior:
|
||||
// um deles tenta reescrever coluna vetada de uma versão que o outro
|
||||
// acabou de publicar → `trg_ai_agent_versions_content_immutable`).
|
||||
// O advisory lock de transação faz a segunda chamada esperar a
|
||||
// primeira COMMITAR e então enxergar o estado final.
|
||||
await cli.query("select pg_advisory_xact_lock(hashtext($1))", [`publish-agent:${creds.org_id}:${nome}`]);
|
||||
|
||||
const { rows: aRows } = await cli.query<{ id: string; published_version_id: string | null }>(
|
||||
`insert into ai_agents (organization_id, name, system_prompt, kind, archived_at)
|
||||
values ($1, $2, 'Agente de teste E2E do gate de elegibilidade.', 'mcp_agent', null)
|
||||
on conflict (organization_id, name) do update set archived_at = null, kind = 'mcp_agent'
|
||||
returning id, published_version_id`,
|
||||
[creds.org_id, nome],
|
||||
);
|
||||
const agentId = aRows[0]!.id;
|
||||
|
||||
// Uma versão publicada cujo canal/credencial JÁ batem com a fixture
|
||||
// serve os três casos — só o `followup` (coluna FORA do trigger de
|
||||
// imutabilidade) precisa acompanhar o `pointerId`. Procuramos por
|
||||
// conteúdo, não pelo ponteiro: o ponteiro pode estar atrás de um
|
||||
// INSERT concorrente que ainda não o moveu.
|
||||
const { rows: pub } = await cli.query<{ id: string }>(
|
||||
`select id from ai_agent_versions
|
||||
where agent_id = $1 and status = 'published'
|
||||
and channel_session_id = $2 and credential_id = $3
|
||||
order by version_number desc limit 1`,
|
||||
[agentId, fix.channel_session_id, fix.credential_id],
|
||||
);
|
||||
|
||||
let versionId: string;
|
||||
if (pub[0]) {
|
||||
versionId = pub[0].id;
|
||||
await cli.query(
|
||||
`update ai_agent_versions set followup = $2::jsonb, published_at = now() where id = $1`,
|
||||
[versionId, followup],
|
||||
);
|
||||
} else {
|
||||
// Nenhuma versão publicada com o conteúdo certo. Um draft existente
|
||||
// pode ser promovido (o trigger só veta UPDATE de linha NÃO-draft);
|
||||
// senão, versão nova. Em ambos os casos, um número maior que o atual.
|
||||
const { rows: dr } = await cli.query<{ id: string }>(
|
||||
`select id from ai_agent_versions where agent_id = $1 and status = 'draft'
|
||||
order by version_number desc limit 1`,
|
||||
[agentId],
|
||||
);
|
||||
if (dr[0]) {
|
||||
versionId = dr[0].id;
|
||||
await cli.query(
|
||||
`update ai_agent_versions
|
||||
set status = 'published', published_at = now(),
|
||||
channel_session_id = $2, credential_id = $3, followup = $4::jsonb
|
||||
where id = $1`,
|
||||
[versionId, fix.channel_session_id, fix.credential_id, followup],
|
||||
);
|
||||
} else {
|
||||
const { rows: nv } = await cli.query<{ id: string }>(
|
||||
`insert into ai_agent_versions
|
||||
(organization_id, agent_id, version_number, system_prompt, provider, model,
|
||||
credential_id, channel_session_id, status, published_at, followup)
|
||||
values ($1, $2,
|
||||
(select coalesce(max(version_number), 0) + 1 from ai_agent_versions where agent_id = $2),
|
||||
'Agente de teste E2E.', 'anthropic', 'claude-sonnet-4-6',
|
||||
$3, $4, 'published', now(), $5::jsonb)
|
||||
returning id`,
|
||||
[creds.org_id, agentId, fix.credential_id, fix.channel_session_id, followup],
|
||||
);
|
||||
versionId = nv[0]!.id;
|
||||
}
|
||||
}
|
||||
await cli.query(`update ai_agents set published_version_id = $2 where id = $1`, [
|
||||
agentId,
|
||||
versionId,
|
||||
]);
|
||||
await cli.query("commit");
|
||||
out({ agentId, versionId });
|
||||
} catch (e) {
|
||||
await cli.query("rollback").catch(() => {});
|
||||
throw e;
|
||||
} finally {
|
||||
cli.release();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// ── archive-agent <agentId> ─────────────────────────────────────────
|
||||
// `ai_agents.archived_at` sozinho já tira o agente do `tem_agente` do
|
||||
// drain (a query dele filtra `a.archived_at is null`) e do gate de
|
||||
// follow-up. Não mexo no `status` da versão — o vocabulário do CHECK
|
||||
// varia e não é preciso aqui.
|
||||
case "archive-agent": {
|
||||
const agentId = args[0];
|
||||
if (!agentId) throw new Error("agentId obrigatório");
|
||||
await pool.query(`update ai_agents set archived_at = now() where id = $1`, [agentId]);
|
||||
out({ ok: true });
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`comando desconhecido: ${cmd ?? "(vazio)"}`);
|
||||
}
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("❌ e2e-elegibilidade-helpers falhou:", err instanceof Error ? err.message : err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,537 @@
|
||||
/**
|
||||
* A bateria de PREFLIGHTS do `scripts/ativar-gate-elegibilidade-ia.ts`, separada
|
||||
* do encanamento (args, `pg.Pool`, `process.exit`) para ser testável — unit com
|
||||
* um pool falso, invariante com Postgres real.
|
||||
*
|
||||
* Cada preflight é uma função `(ctx) => Promise<Resultado>`. Nenhuma escreve
|
||||
* nada: a única escrita do fluxo (`channel_sessions.metadata.ai_gate`) mora no
|
||||
* script, atrás de `--apply`.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import {
|
||||
campanhaWhatsappSchema,
|
||||
normalizarParaMatch,
|
||||
parseCampanhas,
|
||||
type CampanhaWhatsapp,
|
||||
} from "../../lib/ai/elegibilidade/campanha";
|
||||
import {
|
||||
decidirElegibilidade,
|
||||
lerModoDoGate,
|
||||
montarEstadoDeElegibilidade,
|
||||
} from "../../lib/ai/elegibilidade/gate";
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// Tipos
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type Status = "PASS" | "WARN" | "FAIL" | "INFO";
|
||||
export interface Resultado {
|
||||
status: Status;
|
||||
detalhe: string;
|
||||
linhas?: string[];
|
||||
}
|
||||
|
||||
/** O mínimo de `pg.Pool` que os preflights usam. */
|
||||
export interface ConsultaPg {
|
||||
query: (
|
||||
texto: string,
|
||||
params?: unknown[],
|
||||
) => Promise<{ rows: Array<Record<string, unknown>> }>;
|
||||
}
|
||||
|
||||
export interface CtxAtivacao {
|
||||
pool: ConsultaPg;
|
||||
organizationId: string;
|
||||
channelSessionId: string;
|
||||
channelMetadata: Record<string, unknown>;
|
||||
/** raiz do repo — para as checagens de código-fonte (INFO se ausente). */
|
||||
raiz: string;
|
||||
ttlMs: number;
|
||||
alvoModo: "allowlist" | "open";
|
||||
rollback: boolean;
|
||||
opcoes: {
|
||||
permitirSemAgente: boolean;
|
||||
campanhasPerigosasOk: boolean;
|
||||
tamAmostra: number;
|
||||
};
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// Heurística: campanha perigosa (casa conversa comum)
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const TOKENS_GENERICOS = new Set([
|
||||
"oi", "ola", "opa", "hey", "bom", "boa", "dia", "tarde", "noite", "tudo", "bem",
|
||||
"obrigado", "obrigada", "quero", "gostaria", "saber", "mais", "informacao",
|
||||
"informacoes", "info", "duvida", "duvidas", "ajuda", "orcamento", "orcamentos",
|
||||
"preco", "precos", "valor", "valores", "quanto", "custa", "custo", "interesse",
|
||||
"interessado", "interessada", "contratar", "comprar", "atendimento", "falar",
|
||||
"contato", "e", "de", "do", "da", "um", "uma", "para", "pra", "por", "favor",
|
||||
"me", "chama", "voces", "voce", "sobre",
|
||||
]);
|
||||
|
||||
/**
|
||||
* `null` = a campanha é específica o suficiente. String = por que casa demais.
|
||||
* - `contains`: < 15 chars normalizados, OU < 3 palavras, OU todas genéricas;
|
||||
* - `starts_with`: prefixo < 12 chars, OU 1 palavra só.
|
||||
*/
|
||||
export function campanhaPerigosa(c: CampanhaWhatsapp): string | null {
|
||||
campanhaWhatsappSchema.parse(c); // sanidade — não lança (veio de parseCampanhas)
|
||||
const v = normalizarParaMatch(c.match.valor);
|
||||
const palavras = v.split(" ").filter(Boolean);
|
||||
if (c.match.tipo === "contains") {
|
||||
if (v.length < 15) return `match 'contains' com frase de ${v.length} chars — casa conversa comum`;
|
||||
if (palavras.length < 3) return `match 'contains' com só ${palavras.length} palavra(s)`;
|
||||
if (palavras.every((p) => TOKENS_GENERICOS.has(p))) return `todas as palavras da frase são genéricas ("${v}")`;
|
||||
return null;
|
||||
}
|
||||
if (v.length < 12) return `match 'starts_with' com prefixo de ${v.length} chars`;
|
||||
if (palavras.length < 2) return `match 'starts_with' com uma palavra só`;
|
||||
return null;
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function estado(
|
||||
r: Record<string, unknown>,
|
||||
modo: "open" | "allowlist",
|
||||
ttlMs: number,
|
||||
agora: Date,
|
||||
) {
|
||||
return montarEstadoDeElegibilidade({
|
||||
aiGate: modo,
|
||||
forceHuman: r.force_human,
|
||||
assigneeKind: (r.assignee_kind as string | null) ?? null,
|
||||
botSilencedUntil: r.bot_silenced_until as string | null,
|
||||
aiAuthorizedAt: r.ai_authorized_at as string | null,
|
||||
agora,
|
||||
ttlMs,
|
||||
});
|
||||
}
|
||||
|
||||
function lerFonte(raiz: string, arq: string): string | null {
|
||||
try {
|
||||
return readFileSync(resolve(raiz, arq), "utf-8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// Preflights
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function checkSchema0203(ctx: CtxAtivacao): Promise<Resultado> {
|
||||
const { rows } = await ctx.pool.query(
|
||||
`select column_name, data_type, is_nullable, column_default
|
||||
from information_schema.columns
|
||||
where table_schema = 'public' and table_name = 'contacts'
|
||||
and column_name in ('ai_authorized_at','ai_authorized_reason')
|
||||
order by column_name`,
|
||||
);
|
||||
const nomes = rows.map((r) => r.column_name);
|
||||
if (!nomes.includes("ai_authorized_at") || !nomes.includes("ai_authorized_reason")) {
|
||||
return {
|
||||
status: "FAIL",
|
||||
detalhe:
|
||||
"colunas ausentes — a migration 0203 não foi aplicada nesta instalação. " +
|
||||
"Aplique o supabase/baseline.sql (o `update.sh` do kit) ANTES de ligar o gate.",
|
||||
linhas: [`encontradas: ${nomes.join(", ") || "(nenhuma)"}`],
|
||||
};
|
||||
}
|
||||
const at = rows.find((r) => r.column_name === "ai_authorized_at")!;
|
||||
const rz = rows.find((r) => r.column_name === "ai_authorized_reason")!;
|
||||
const problemas: string[] = [];
|
||||
if (at.data_type !== "timestamp with time zone") problemas.push(`ai_authorized_at é ${at.data_type as string}, esperado timestamptz`);
|
||||
if (at.is_nullable !== "YES") problemas.push("ai_authorized_at NOT NULL — a migration certa cria a coluna anulável");
|
||||
if (at.column_default !== null) problemas.push(`ai_authorized_at tem default (${at.column_default as string}) — não deveria`);
|
||||
if (rz.is_nullable !== "YES") problemas.push("ai_authorized_reason NOT NULL — deveria ser anulável");
|
||||
if (problemas.length > 0) return { status: "FAIL", detalhe: "colunas existem mas com forma errada", linhas: problemas };
|
||||
return { status: "PASS", detalhe: "ai_authorized_at + ai_authorized_reason presentes, anuláveis, sem default/constraint" };
|
||||
}
|
||||
|
||||
export async function checkQueryElegibilidade(ctx: CtxAtivacao): Promise<Resultado> {
|
||||
const linhas: string[] = [];
|
||||
try {
|
||||
const { rows } = await ctx.pool.query(
|
||||
`select cs.metadata->>'ai_gate' as ai_gate, ct.force_human, cv.assignee_kind,
|
||||
cv.bot_silenced_until, ct.ai_authorized_at
|
||||
from conversations cv
|
||||
join contacts ct on ct.id = cv.contact_id and ct.organization_id = cv.organization_id
|
||||
join channel_sessions cs on cs.id = cv.channel_session_id and cs.organization_id = cv.organization_id
|
||||
where cv.organization_id = $1 and cv.id = $2`,
|
||||
[ctx.organizationId, "00000000-0000-4000-8000-000000000000"],
|
||||
);
|
||||
linhas.push(`query base (consulta-pg.ts) resolveu — ${rows.length} linha(s) para o id de teste`);
|
||||
} catch (e) {
|
||||
return {
|
||||
status: "FAIL",
|
||||
detalhe: "a query base da elegibilidade não roda neste schema",
|
||||
linhas: [e instanceof Error ? e.message : String(e)],
|
||||
};
|
||||
}
|
||||
const { rows: real } = await ctx.pool.query(
|
||||
`select cv.id, cs.metadata->>'ai_gate' as ai_gate, ct.force_human, cv.assignee_kind,
|
||||
cv.bot_silenced_until, ct.ai_authorized_at
|
||||
from conversations cv
|
||||
join contacts ct on ct.id = cv.contact_id and ct.organization_id = cv.organization_id
|
||||
join channel_sessions cs on cs.id = cv.channel_session_id and cs.organization_id = cv.organization_id
|
||||
where cv.organization_id = $1 and cv.channel_session_id = $2 and cv.is_group = false
|
||||
order by cv.last_message_at desc nulls last
|
||||
limit 1`,
|
||||
[ctx.organizationId, ctx.channelSessionId],
|
||||
);
|
||||
if (real[0]) {
|
||||
const agora = new Date();
|
||||
const hoje = decidirElegibilidade(estado(real[0], lerModoDoGate(real[0].ai_gate), ctx.ttlMs, agora));
|
||||
const comGate = decidirElegibilidade(estado(real[0], "allowlist", ctx.ttlMs, agora));
|
||||
linhas.push(
|
||||
`conversa real ${real[0].id as string}: hoje → ${hoje.permite ? "responde" : "não responde"} (${hoje.motivo}); ` +
|
||||
`com allowlist → ${comGate.permite ? "responde" : "não responde"} (${comGate.motivo})`,
|
||||
);
|
||||
} else {
|
||||
linhas.push("nenhuma conversa neste canal para exercitar a regra com dado real");
|
||||
}
|
||||
return { status: "PASS", detalhe: "a leitura de elegibilidade funciona", linhas };
|
||||
}
|
||||
|
||||
export async function checkAgentePublicado(ctx: CtxAtivacao): Promise<Resultado> {
|
||||
const { rows } = await ctx.pool.query(
|
||||
`select
|
||||
exists(select 1 from ai_agents a
|
||||
join ai_agent_versions v on v.id = a.published_version_id
|
||||
where a.organization_id = $1 and a.archived_at is null
|
||||
and v.status = 'published' and v.channel_session_id = $2) as tem_agente,
|
||||
exists(select 1 from ai_routers r
|
||||
where r.organization_id = $1 and r.is_active and r.channel_session_id = $2
|
||||
and (r.fallback_agent_id is not null
|
||||
or exists(select 1 from ai_router_members m where m.router_id = r.id))) as tem_roteador,
|
||||
exists(select 1 from ai_agents a
|
||||
where a.organization_id = $1 and a.is_active and a.archived_at is null) as tem_agente_legado`,
|
||||
[ctx.organizationId, ctx.channelSessionId],
|
||||
);
|
||||
const { tem_agente, tem_roteador, tem_agente_legado } = rows[0] as Record<string, boolean>;
|
||||
if (tem_agente || tem_roteador) {
|
||||
return {
|
||||
status: "PASS",
|
||||
detalhe: `caminho do agent-engine ativo (agente publicado: ${tem_agente}, roteador: ${tem_roteador})`,
|
||||
};
|
||||
}
|
||||
const linhas = [
|
||||
"nenhuma versão de agente publicada nem roteador ativo para este canal.",
|
||||
tem_agente_legado
|
||||
? "há agente com `is_active` (caminho legado do ai-response-worker) — que também respeita o gate."
|
||||
: "não há agente algum configurado — ligar o gate não tem efeito visível até publicar um.",
|
||||
];
|
||||
if (ctx.opcoes.permitirSemAgente) {
|
||||
return { status: "WARN", detalhe: "sem agente publicado — seguindo por --permitir-sem-agente", linhas };
|
||||
}
|
||||
return {
|
||||
status: "FAIL",
|
||||
detalhe: "sem agente publicado no canal. Publique um antes, ou passe --permitir-sem-agente.",
|
||||
linhas,
|
||||
};
|
||||
}
|
||||
|
||||
const CAMINHOS_COM_GATE: Array<[string, string]> = [
|
||||
["lib/agent-engine/edge/crm/drain.ts", "decidirElegibilidadeDaConversa"],
|
||||
["lib/agent-engine/agent/inbound-turn.ts", "decidirElegibilidadeDaConversa"],
|
||||
["workers/ai-response-worker.ts", "decidirElegibilidadeDaConversaViaSupabase"],
|
||||
["lib/followup/enviar-texto-fixo.ts", "decidirElegibilidadeDaConversaViaSupabase"],
|
||||
["lib/ai/runtime/agent.ts", "decidirElegibilidadeDaConversaViaSupabase"],
|
||||
["lib/ai/handoff/orchestrator.ts", "decidirElegibilidadeDaConversaViaSupabase"],
|
||||
["workers/ai-sentiment-worker.ts", "decidirElegibilidadeDaConversaViaSupabase"],
|
||||
["lib/followup/silence-sweep.ts", "ai_gate"],
|
||||
];
|
||||
|
||||
export async function checkCoberturaDosCaminhos(ctx: CtxAtivacao): Promise<Resultado> {
|
||||
const faltando: string[] = [];
|
||||
let semFonte = 0;
|
||||
for (const [arq, marcador] of CAMINHOS_COM_GATE) {
|
||||
const src = lerFonte(ctx.raiz, arq);
|
||||
if (src === null) {
|
||||
semFonte++;
|
||||
continue;
|
||||
}
|
||||
if (!src.includes(marcador)) faltando.push(`${arq} — sem \`${marcador}\``);
|
||||
}
|
||||
if (faltando.length > 0) {
|
||||
return { status: "FAIL", detalhe: "um caminho de resposta perdeu o gate — NÃO ative até consertar", linhas: faltando };
|
||||
}
|
||||
if (semFonte === CAMINHOS_COM_GATE.length) {
|
||||
return {
|
||||
status: "INFO",
|
||||
detalhe:
|
||||
"código-fonte não disponível aqui (imagem buildada) — a cobertura dos caminhos é garantida pelo CI " +
|
||||
"(testes J20 em docs/testing/user-journey-map.md). Rode de um checkout para a verificação estática.",
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: "PASS",
|
||||
detalhe: `${CAMINHOS_COM_GATE.length - semFonte}/${CAMINHOS_COM_GATE.length} caminhos verificados — todos consultam a mesma regra`,
|
||||
};
|
||||
}
|
||||
|
||||
export async function checkImpactoConversas(ctx: CtxAtivacao): Promise<Resultado> {
|
||||
const CAP = 50000;
|
||||
const { rows } = await ctx.pool.query(
|
||||
`select cv.id as conversation_id, cv.status, cv.last_inbound_at, cv.bot_silenced_until, cv.assignee_kind,
|
||||
coalesce(ct.name, ct.display_name) as name, ct.phone_number, ct.force_human, ct.ai_authorized_at
|
||||
from conversations cv
|
||||
join contacts ct on ct.id = cv.contact_id and ct.organization_id = cv.organization_id
|
||||
where cv.organization_id = $1 and cv.channel_session_id = $2 and cv.is_group = false
|
||||
limit ${CAP}`,
|
||||
[ctx.organizationId, ctx.channelSessionId],
|
||||
);
|
||||
const agora = new Date();
|
||||
const perdem: Array<Record<string, unknown>> = [];
|
||||
let mantem = 0;
|
||||
let jaBloqueada = 0;
|
||||
for (const r of rows) {
|
||||
const hoje = decidirElegibilidade(estado(r, "open", ctx.ttlMs, agora));
|
||||
const depois = decidirElegibilidade(estado(r, "allowlist", ctx.ttlMs, agora));
|
||||
if (hoje.permite && !depois.permite) perdem.push(r);
|
||||
else if (depois.permite) mantem++;
|
||||
else jaBloqueada++;
|
||||
}
|
||||
const capado = rows.length === CAP;
|
||||
const recente = (r: Record<string, unknown>) =>
|
||||
r.last_inbound_at != null &&
|
||||
agora.getTime() - new Date(r.last_inbound_at as string).getTime() < 7 * 24 * 3600 * 1000;
|
||||
const perdemAtivas = perdem.filter(recente).length;
|
||||
const mascara = (t: unknown) => (typeof t === "string" && t ? t.replace(/\d(?=\d{4})/g, "•") : "(sem)");
|
||||
const amostra = perdem
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date((b.last_inbound_at as string) ?? 0).getTime() -
|
||||
new Date((a.last_inbound_at as string) ?? 0).getTime(),
|
||||
)
|
||||
.slice(0, ctx.opcoes.tamAmostra)
|
||||
.map(
|
||||
(r) =>
|
||||
` ${r.conversation_id as string} · ${((r.name as string) ?? "(sem nome)").slice(0, 24).padEnd(24)} · ` +
|
||||
`${mascara(r.phone_number)} · status=${r.status as string} · ` +
|
||||
`último inbound: ${r.last_inbound_at ? new Date(r.last_inbound_at as string).toISOString().slice(0, 10) : "(nunca)"}`,
|
||||
);
|
||||
return {
|
||||
status: "INFO",
|
||||
detalhe:
|
||||
`${perdem.length} conversa(s) deixam de ser atendidas pela IA ao ligar (dessas, ${perdemAtivas} com inbound nos últimos 7 dias). ` +
|
||||
`${mantem} seguem elegíveis (contato já autorizado por origem). ` +
|
||||
`${jaBloqueada} já não recebiam IA hoje (handoff/silêncio/dono humano).` +
|
||||
(capado ? ` ⚠️ amostrado em ${CAP} conversas — o total pode ser maior.` : ""),
|
||||
linhas: amostra.length
|
||||
? [`amostra (as ${amostra.length} mais recentes que perdem a IA — telefone mascarado):`, ...amostra]
|
||||
: ["nenhuma conversa perde a IA (nenhuma estava sendo atendida automaticamente)"],
|
||||
};
|
||||
}
|
||||
|
||||
export async function checkCampanhas(ctx: CtxAtivacao): Promise<Resultado> {
|
||||
const { rows } = await ctx.pool.query(
|
||||
`select settings->'campanhas_whatsapp' as c from organizations where id = $1`,
|
||||
[ctx.organizationId],
|
||||
);
|
||||
const raw = rows[0]?.c ?? null;
|
||||
if (raw === null || (Array.isArray(raw) && raw.length === 0)) {
|
||||
return {
|
||||
status: "INFO",
|
||||
detalhe:
|
||||
"nenhuma campanha registrada. OK — o Respondi e a retomada manual continuam autorizando. " +
|
||||
"Campanha Meta/Google que cai direto no WhatsApp só autoriza quando registrada aqui.",
|
||||
};
|
||||
}
|
||||
if (!Array.isArray(raw)) {
|
||||
return { status: "FAIL", detalhe: "`campanhas_whatsapp` existe mas não é um array — corrija o JSON antes de ligar" };
|
||||
}
|
||||
const validas = parseCampanhas(raw);
|
||||
const invalidas = raw.length - validas.length;
|
||||
const linhas: string[] = [];
|
||||
if (invalidas > 0) linhas.push(`${invalidas} entrada(s) descartada(s) por formato inválido (schema Zod) — ignoradas em runtime`);
|
||||
|
||||
const perigos: string[] = [];
|
||||
for (const c of validas) {
|
||||
const motivo = campanhaPerigosa(c);
|
||||
const escopo = c.channel_session_id
|
||||
? c.channel_session_id === ctx.channelSessionId
|
||||
? "ESTE canal"
|
||||
: `outro canal (${c.channel_session_id})`
|
||||
: "qualquer canal da org";
|
||||
linhas.push(` "${c.id}" [${escopo}] · ${c.match.tipo} "${c.match.valor}"` + (motivo ? ` ⚠️ ${motivo}` : " ✓"));
|
||||
if (motivo && (!c.channel_session_id || c.channel_session_id === ctx.channelSessionId)) {
|
||||
perigos.push(`"${c.id}": ${motivo}`);
|
||||
}
|
||||
}
|
||||
if (perigos.length > 0) {
|
||||
if (ctx.opcoes.campanhasPerigosasOk) {
|
||||
return { status: "WARN", detalhe: "campanha de match genérico — seguindo por --campanhas-perigosas-ok", linhas: [...linhas, ...perigos] };
|
||||
}
|
||||
return {
|
||||
status: "FAIL",
|
||||
detalhe:
|
||||
`${perigos.length} campanha(s) com match que casa conversa comum — ligariam a IA para quase todo mundo, ` +
|
||||
`derrotando o gate. Ajuste a frase (específica, longa) ou passe --campanhas-perigosas-ok.`,
|
||||
linhas: [...linhas, "", "perigosas:", ...perigos],
|
||||
};
|
||||
}
|
||||
if (invalidas > 0) {
|
||||
return {
|
||||
status: "WARN",
|
||||
detalhe:
|
||||
`${invalidas} de ${raw.length} entrada(s) de campanha são inválidas e serão IGNORADAS em runtime — ` +
|
||||
`o operador provavelmente acha que elas funcionam. Corrija o JSON (schema em lib/ai/elegibilidade/campanha.ts).`,
|
||||
linhas,
|
||||
};
|
||||
}
|
||||
return { status: "PASS", detalhe: `${validas.length} campanha(s) válida(s), nenhuma com match genérico`, linhas };
|
||||
}
|
||||
|
||||
export async function checkRespondiAutoriza(ctx: CtxAtivacao): Promise<Resultado> {
|
||||
const linhas: string[] = [];
|
||||
const { rows } = await ctx.pool.query(
|
||||
`select name, is_active from webhook_sources where organization_id = $1 order by created_at`,
|
||||
[ctx.organizationId],
|
||||
);
|
||||
const ativas = rows.filter((r) => r.is_active);
|
||||
linhas.push(
|
||||
rows.length === 0
|
||||
? "nenhuma `webhook_sources` — nenhum formulário externo entrega leads nesta org ainda."
|
||||
: `${ativas.length}/${rows.length} fonte(s) de webhook ativa(s): ${ativas.map((r) => r.name as string).join(", ") || "(nenhuma ativa)"}`,
|
||||
);
|
||||
|
||||
const rota = lerFonte(ctx.raiz, "app/api/v1/webhooks/in/[token]/route.ts");
|
||||
const fonteOk = rota === null ? null : rota.includes("autorizarContatoParaIA") && rota.includes("respondi:");
|
||||
if (fonteOk === false) {
|
||||
return { status: "FAIL", detalhe: "a rota de webhook NÃO chama `autorizarContatoParaIA` — o Respondi não autorizaria", linhas };
|
||||
}
|
||||
linhas.push(
|
||||
fonteOk === true
|
||||
? "rota de webhook chama `autorizarContatoParaIA` com reason `respondi:<form>:<sub>` — cada submissão autoriza SÓ aquele contato"
|
||||
: "código não verificável aqui — comportamento coberto por J20.6 no user-journey-map",
|
||||
);
|
||||
|
||||
const { rows: g } = await ctx.pool.query(
|
||||
`select has_column_privilege('service_role', 'public.contacts', 'ai_authorized_at', 'UPDATE') as pode`,
|
||||
);
|
||||
if (g[0]?.pode === false) {
|
||||
return { status: "FAIL", detalhe: "service_role NÃO tem UPDATE em contacts.ai_authorized_at — o webhook não conseguiria autorizar", linhas };
|
||||
}
|
||||
linhas.push("service_role pode UPDATE contacts.ai_authorized_at — autorização individual funciona");
|
||||
return {
|
||||
status: rows.length === 0 ? "WARN" : "PASS",
|
||||
detalhe:
|
||||
rows.length === 0
|
||||
? "sem fonte de webhook configurada — configure o Respondi para os leads novos entrarem e se autorizarem"
|
||||
: "leads novos do Respondi conseguem se autorizar individualmente pela origem elegível",
|
||||
linhas,
|
||||
};
|
||||
}
|
||||
|
||||
export async function checkDenyByDefault(ctx: CtxAtivacao): Promise<Resultado> {
|
||||
const linhas: string[] = [];
|
||||
const agora = new Date();
|
||||
|
||||
const { rows: rebeldes } = await ctx.pool.query(
|
||||
`select count(*)::int as n from contacts
|
||||
where organization_id = $1 and ai_authorized_at is not null
|
||||
and (ai_authorized_reason is null
|
||||
or ai_authorized_reason !~ '^(respondi:|campanha:|automacao:|retomada_manual)')`,
|
||||
[ctx.organizationId],
|
||||
);
|
||||
const nRebeldes = Number(rebeldes[0]?.n ?? 0);
|
||||
if (nRebeldes > 0) {
|
||||
return {
|
||||
status: "FAIL",
|
||||
detalhe:
|
||||
`${nRebeldes} contato(s) com ai_authorized_at mas SEM reason de origem elegível — ` +
|
||||
`alguém autorizou fora do produto. Investigue antes de ligar o gate.`,
|
||||
};
|
||||
}
|
||||
linhas.push("toda autorização existente veio de uma origem elegível (respondi:/campanha:/automacao:/retomada_manual)");
|
||||
|
||||
const { rows: antigos } = await ctx.pool.query(
|
||||
`select ct.id, ct.created_at, ct.ai_authorized_at, ct.force_human,
|
||||
cv.assignee_kind, cv.bot_silenced_until
|
||||
from contacts ct
|
||||
join conversations cv on cv.contact_id = ct.id and cv.organization_id = ct.organization_id
|
||||
where ct.organization_id = $1 and cv.channel_session_id = $2 and ct.ai_authorized_at is null
|
||||
order by ct.created_at asc
|
||||
limit 8`,
|
||||
[ctx.organizationId, ctx.channelSessionId],
|
||||
);
|
||||
let todosNegados = true;
|
||||
for (const r of antigos) {
|
||||
const d = decidirElegibilidade(estado(r, "allowlist", ctx.ttlMs, agora));
|
||||
if (d.permite) todosNegados = false;
|
||||
linhas.push(
|
||||
` contato ${r.id as string} (desde ${new Date(r.created_at as string).toISOString().slice(0, 10)}) → ` +
|
||||
`${d.permite ? "❌ AUTORIZADO?!" : "não autorizado"} (${d.motivo})`,
|
||||
);
|
||||
}
|
||||
if (antigos.length === 0) linhas.push(" (nenhum contato antigo sem autorização neste canal para amostrar)");
|
||||
if (!todosNegados) {
|
||||
return { status: "FAIL", detalhe: "um contato antigo/sem-origem seria atendido pela IA em modo allowlist — a regra está furada", linhas };
|
||||
}
|
||||
|
||||
const simNova = decidirElegibilidade(
|
||||
montarEstadoDeElegibilidade({ aiGate: "allowlist", forceHuman: false, assigneeKind: "ai", botSilencedUntil: null, aiAuthorizedAt: null, agora, ttlMs: ctx.ttlMs }),
|
||||
);
|
||||
const simExpirada = decidirElegibilidade(
|
||||
montarEstadoDeElegibilidade({
|
||||
aiGate: "allowlist", forceHuman: false, assigneeKind: "ai", botSilencedUntil: null,
|
||||
aiAuthorizedAt: new Date(agora.getTime() - ctx.ttlMs - 86_400_000), agora, ttlMs: ctx.ttlMs,
|
||||
}),
|
||||
);
|
||||
linhas.push(
|
||||
` simulação "mensagem nova, contato nunca autorizado" → ${simNova.permite ? "❌" : "não responde"} (${simNova.motivo})`,
|
||||
` simulação "autorização mais velha que o TTL (${Math.round(ctx.ttlMs / 86_400_000)}d)" → ${simExpirada.permite ? "❌" : "não responde"} (${simExpirada.motivo})`,
|
||||
);
|
||||
if (simNova.permite || simExpirada.permite) {
|
||||
return { status: "FAIL", detalhe: "a regra pura autorizou um caso que não deveria", linhas };
|
||||
}
|
||||
return { status: "PASS", detalhe: "histórico, contato antigo, conversa anterior e submissão vencida NÃO autorizam", linhas };
|
||||
}
|
||||
|
||||
export function checkPlanoDeEscrita(ctx: CtxAtivacao): Resultado {
|
||||
const atual = lerModoDoGate(ctx.channelMetadata.ai_gate);
|
||||
const linhas = [
|
||||
`ALVO: ${ctx.alvoModo === "allowlist" ? "LIGAR (allowlist)" : "DESLIGAR (open)"} o gate do canal ${ctx.channelSessionId}`,
|
||||
`estado atual: metadata.ai_gate = ${JSON.stringify(ctx.channelMetadata.ai_gate ?? null)} (${atual})`,
|
||||
"",
|
||||
"ÚNICA escrita que o --apply faz:",
|
||||
` update channel_sessions`,
|
||||
` set metadata = jsonb_set(coalesce(metadata,'{}'::jsonb), '{ai_gate}', '"${ctx.alvoModo}"'), updated_at = now()`,
|
||||
` where id = '${ctx.channelSessionId}' and organization_id = '${ctx.organizationId}';`,
|
||||
"",
|
||||
"NÃO escreve em: contacts (ZERO autorização em massa), conversations, ai_agents, organizations.",
|
||||
];
|
||||
if (atual === ctx.alvoModo) {
|
||||
return { status: "WARN", detalhe: `o gate JÁ está em '${atual}' — o --apply seria no-op`, linhas };
|
||||
}
|
||||
return { status: "INFO", detalhe: `transição ${atual} → ${ctx.alvoModo}`, linhas };
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// A lista ordenada
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function montarPreflights(
|
||||
ctx: CtxAtivacao,
|
||||
): Array<{ nome: string; run: () => Promise<Resultado> }> {
|
||||
return [
|
||||
{ nome: "Schema · migration 0203 (contacts.ai_authorized_at)", run: () => checkSchema0203(ctx) },
|
||||
{ nome: "Query de elegibilidade · executa sem erro", run: () => checkQueryElegibilidade(ctx) },
|
||||
{ nome: "Agente · há quem a IA use neste canal", run: () => checkAgentePublicado(ctx) },
|
||||
{ nome: "Cobertura · todo caminho de resposta automática respeita o gate", run: () => checkCoberturaDosCaminhos(ctx) },
|
||||
{ nome: "Impacto · conversas que ficam sem IA ao ligar 'allowlist'", run: () => checkImpactoConversas(ctx) },
|
||||
{ nome: "Campanhas · organizations.settings.campanhas_whatsapp", run: () => checkCampanhas(ctx) },
|
||||
{ nome: "Respondi · leads novos podem se autorizar pela origem", run: () => checkRespondiAutoriza(ctx) },
|
||||
{ nome: "Deny-by-default · histórico / contato antigo / mensagem sem origem NÃO autoriza", run: () => checkDenyByDefault(ctx) },
|
||||
{ nome: "Escrita · o que o --apply vai (e não vai) mudar", run: async () => checkPlanoDeEscrita(ctx) },
|
||||
];
|
||||
}
|
||||
|
||||
export type { CampanhaWhatsapp };
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Seed E2E do GATE DE ELEGIBILIDADE DA IA (J20) — fixtures que as três specs
|
||||
* compartilham:
|
||||
*
|
||||
* tests/e2e/j20-elegibilidade-respondi.spec.ts (J20.6)
|
||||
* tests/e2e/j20-elegibilidade-followup.spec.ts (J20.12)
|
||||
* tests/e2e/j20-elegibilidade-atendimento-manual.spec.ts (J20.18)
|
||||
*
|
||||
* O que cria (idempotente por nome/label/session_name únicos, service role,
|
||||
* grava o bloco `elegibilidade` em .e2e-creds.json — mesmo padrão de
|
||||
* scripts/seed-e2e-followup-agent.ts):
|
||||
*
|
||||
* 1. `channel_sessions` "e2e-elegibilidade-session" com
|
||||
* `metadata.ai_gate = 'allowlist'` — o CANAL onde o gate está LIGADO.
|
||||
* É o único lugar em que as specs escrevem `ai_gate`: nenhum contato é
|
||||
* autorizado em massa. `status='WORKING'`, warmup completo.
|
||||
* 2. `ai_provider_credentials` validada (bytea placeholder — nunca decifrada
|
||||
* nos caminhos que a suíte exercita; `validated_at` só destrava o publish
|
||||
* do agente, igual ao `prepare-agent-fixtures` do followup-journey).
|
||||
* 3. `webhook_sources` (kind lead_capture) apontando para o 1º funil da org —
|
||||
* a URL onde J20.6 posta a submissão do Respondi.
|
||||
*
|
||||
* NÃO cria agente nem fluxo de follow-up: cada spec publica o seu pela API
|
||||
* REAL depois do login (é o caminho de produção; ver followup-journey.spec.ts).
|
||||
*
|
||||
* Run: npx tsx scripts/seed-e2e-elegibilidade.ts
|
||||
*/
|
||||
import { randomUUID } from "node:crypto";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
|
||||
import { anunciarDestino, credenciaisSupabaseDeTeste } from "./lib/env-de-teste";
|
||||
|
||||
const credenciais = credenciaisSupabaseDeTeste();
|
||||
anunciarDestino("seed-e2e-elegibilidade", credenciais);
|
||||
|
||||
const admin = createClient(credenciais.url, credenciais.serviceRole, {
|
||||
auth: { autoRefreshToken: false, persistSession: false },
|
||||
});
|
||||
|
||||
const CREDS_PATH = path.join(process.cwd(), ".e2e-creds.json");
|
||||
|
||||
const SESSION_NAME = "e2e-elegibilidade-session";
|
||||
const CREDENTIAL_LABEL = "E2E Elegibilidade — credencial";
|
||||
const SOURCE_NAME = "E2E Elegibilidade — captação Respondi";
|
||||
|
||||
interface Creds {
|
||||
org_id: string;
|
||||
elegibilidade?: {
|
||||
channel_session_id: string;
|
||||
waha_path_token: string;
|
||||
credential_id: string;
|
||||
pipeline_id: string;
|
||||
stage_id: string;
|
||||
webhook_source_id: string;
|
||||
webhook_source_token: string;
|
||||
};
|
||||
}
|
||||
|
||||
async function primeiroFunil(orgId: string): Promise<{ pipelineId: string; stageId: string }> {
|
||||
const { data, error } = await admin
|
||||
.from("crm_pipelines")
|
||||
.select("id, crm_stages(id, position)")
|
||||
.eq("organization_id", orgId)
|
||||
.eq("is_archived", false)
|
||||
.order("created_at", { ascending: true })
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
if (error) throw new Error(`crm_pipelines: ${error.message}`);
|
||||
const pipelineId = (data as { id: string } | null)?.id;
|
||||
const stages = ((data as { crm_stages?: Array<{ id: string; position: number }> } | null)
|
||||
?.crm_stages ?? []).slice().sort((a, b) => a.position - b.position);
|
||||
const stageId = stages[0]?.id;
|
||||
if (!pipelineId || !stageId) {
|
||||
throw new Error(
|
||||
"A org de teste não tem funil com etapa. Rode antes: npx tsx scripts/seed-e2e-funis.ts",
|
||||
);
|
||||
}
|
||||
return { pipelineId, stageId };
|
||||
}
|
||||
|
||||
async function ensureChannelSession(orgId: string): Promise<{ id: string; token: string }> {
|
||||
const { data: existing, error: selErr } = await admin
|
||||
.from("channel_sessions")
|
||||
.select("id, webhook_path_token, metadata")
|
||||
.eq("organization_id", orgId)
|
||||
.eq("waha_session_name", SESSION_NAME)
|
||||
.maybeSingle();
|
||||
if (selErr) throw new Error(`channel_sessions select: ${selErr.message}`);
|
||||
|
||||
if (existing) {
|
||||
const row = existing as { id: string; webhook_path_token: string; metadata: Record<string, unknown> };
|
||||
// Auto-cura: garante que o gate está LIGADO e a sessão WORKING (uma corrida
|
||||
// anterior pode ter deixado noutro estado).
|
||||
await admin
|
||||
.from("channel_sessions")
|
||||
.update({
|
||||
status: "WORKING",
|
||||
metadata: { ...(row.metadata ?? {}), ai_gate: "allowlist" },
|
||||
})
|
||||
.eq("id", row.id);
|
||||
return { id: row.id, token: row.webhook_path_token };
|
||||
}
|
||||
|
||||
const { data, error } = await admin
|
||||
.from("channel_sessions")
|
||||
.insert({
|
||||
organization_id: orgId,
|
||||
waha_session_name: SESSION_NAME,
|
||||
display_name: "Número Elegibilidade E2E",
|
||||
status: "WORKING",
|
||||
warmup_completed_at: new Date().toISOString(),
|
||||
webhook_secret_encrypted: "\\x00",
|
||||
metadata: { ai_gate: "allowlist" },
|
||||
} as never)
|
||||
.select("id, webhook_path_token")
|
||||
.single();
|
||||
if (error || !data) throw new Error(`channel_sessions insert: ${error?.message}`);
|
||||
const row = data as { id: string; webhook_path_token: string };
|
||||
return { id: row.id, token: row.webhook_path_token };
|
||||
}
|
||||
|
||||
async function ensureCredential(orgId: string): Promise<string> {
|
||||
const { data: existing } = await admin
|
||||
.from("ai_provider_credentials")
|
||||
.select("id")
|
||||
.eq("organization_id", orgId)
|
||||
.eq("label", CREDENTIAL_LABEL)
|
||||
.maybeSingle();
|
||||
if (existing) {
|
||||
await admin
|
||||
.from("ai_provider_credentials")
|
||||
.update({ validated_at: new Date().toISOString(), is_active: true })
|
||||
.eq("id", (existing as { id: string }).id);
|
||||
return (existing as { id: string }).id;
|
||||
}
|
||||
|
||||
const { data, error } = await admin
|
||||
.from("ai_provider_credentials")
|
||||
.insert({
|
||||
organization_id: orgId,
|
||||
provider: "anthropic",
|
||||
label: CREDENTIAL_LABEL,
|
||||
api_key_encrypted: "\\x00",
|
||||
api_key_iv: "\\x00",
|
||||
api_key_tag: "\\x00",
|
||||
api_key_last4: "e2e1",
|
||||
is_active: true,
|
||||
validated_at: new Date().toISOString(),
|
||||
} as never)
|
||||
.select("id")
|
||||
.single();
|
||||
if (error || !data) throw new Error(`ai_provider_credentials insert: ${error?.message}`);
|
||||
return (data as { id: string }).id;
|
||||
}
|
||||
|
||||
async function ensureWebhookSource(
|
||||
orgId: string,
|
||||
pipelineId: string,
|
||||
stageId: string,
|
||||
): Promise<{ id: string; token: string }> {
|
||||
const { data: existing } = await admin
|
||||
.from("webhook_sources")
|
||||
.select("id, path_token")
|
||||
.eq("organization_id", orgId)
|
||||
.eq("name", SOURCE_NAME)
|
||||
.maybeSingle();
|
||||
if (existing) {
|
||||
const row = existing as { id: string; path_token: string };
|
||||
return { id: row.id, token: row.path_token };
|
||||
}
|
||||
|
||||
const token = randomUUID().replace(/-/g, "");
|
||||
const { data, error } = await admin
|
||||
.from("webhook_sources")
|
||||
.insert({
|
||||
organization_id: orgId,
|
||||
name: SOURCE_NAME,
|
||||
path_token: token,
|
||||
kind: "lead_capture",
|
||||
default_pipeline_id: pipelineId,
|
||||
default_stage_id: stageId,
|
||||
field_map: {},
|
||||
is_active: true,
|
||||
} as never)
|
||||
.select("id, path_token")
|
||||
.single();
|
||||
if (error || !data) throw new Error(`webhook_sources insert: ${error?.message}`);
|
||||
const row = data as { id: string; path_token: string };
|
||||
return { id: row.id, token: row.path_token };
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
if (!fs.existsSync(CREDS_PATH)) {
|
||||
throw new Error(
|
||||
"Falta .e2e-creds.json — rode antes: npx tsx scripts/seed-e2e-credentials.ts",
|
||||
);
|
||||
}
|
||||
const creds = JSON.parse(fs.readFileSync(CREDS_PATH, "utf8")) as Creds;
|
||||
const orgId = creds.org_id;
|
||||
if (!orgId) throw new Error(".e2e-creds.json sem org_id — re-rode seed-e2e-credentials.ts");
|
||||
|
||||
const { pipelineId, stageId } = await primeiroFunil(orgId);
|
||||
const session = await ensureChannelSession(orgId);
|
||||
const credentialId = await ensureCredential(orgId);
|
||||
const source = await ensureWebhookSource(orgId, pipelineId, stageId);
|
||||
|
||||
creds.elegibilidade = {
|
||||
channel_session_id: session.id,
|
||||
waha_path_token: session.token,
|
||||
credential_id: credentialId,
|
||||
pipeline_id: pipelineId,
|
||||
stage_id: stageId,
|
||||
webhook_source_id: source.id,
|
||||
webhook_source_token: source.token,
|
||||
};
|
||||
fs.writeFileSync(CREDS_PATH, JSON.stringify(creds, null, 2));
|
||||
|
||||
console.info(
|
||||
`\n✅ Seed elegibilidade completo.\n` +
|
||||
` channel_session=${session.id} (ai_gate=allowlist, token=${session.token})\n` +
|
||||
` credential=${credentialId}\n` +
|
||||
` webhook_source=${source.id} (token=${source.token})`,
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("❌ Seed elegibilidade falhou:", err instanceof Error ? err.message : err);
|
||||
process.exit(1);
|
||||
});
|
||||
+57
-33
@@ -17227,6 +17227,63 @@ comment on column public.catalog_products.controla_estoque is
|
||||
'false = item que não se conta (decant, sob encomenda). A busca do agente não o esconde por quantidade zero.';
|
||||
|
||||
|
||||
-- ---- versão de acervo conta por MATERIAL, não por agente (migration 0205) ----
|
||||
--
|
||||
-- O índice `ai_kbv_version_unique` era `(agent_id, version_number)`, mas desde a
|
||||
-- 0181 o número é contado por `knowledge_source_id`. Toda fonte nova nasce com
|
||||
-- `version_number = 1`, então a SEGUNDA fonte do mesmo agente colidia com a
|
||||
-- primeira e nunca indexava — a tela dizia "pronto" e `chunks_count` ficava 0.
|
||||
-- Determinístico, não corrida. Medido em produção: 5 materiais, 1 indexou.
|
||||
--
|
||||
-- Dois índices parciais porque há dois regimes: versões anteriores à 0181 têm
|
||||
-- `knowledge_source_id` NULL e guardam o invariante antigo (por agente); sem o
|
||||
-- segundo índice elas ficariam sem restrição, já que NULL não colide com NULL.
|
||||
delete from public.ai_knowledge_versions v
|
||||
where v.knowledge_source_id is not null
|
||||
and exists (
|
||||
select 1 from public.ai_knowledge_versions o
|
||||
where o.knowledge_source_id = v.knowledge_source_id
|
||||
and o.version_number = v.version_number
|
||||
and o.id < v.id
|
||||
);
|
||||
|
||||
alter table public.ai_knowledge_versions
|
||||
drop constraint if exists ai_kbv_version_unique;
|
||||
|
||||
drop index if exists public.ai_kbv_version_unique;
|
||||
|
||||
create unique index if not exists ai_kbv_version_por_fonte
|
||||
on public.ai_knowledge_versions (knowledge_source_id, version_number)
|
||||
where knowledge_source_id is not null;
|
||||
|
||||
create unique index if not exists ai_kbv_version_por_agente_legado
|
||||
on public.ai_knowledge_versions (agent_id, version_number)
|
||||
where knowledge_source_id is null;
|
||||
|
||||
-- ---- elegibilidade da IA por origem do lead (migration 0206) ----
|
||||
--
|
||||
-- Gate OPT-IN por canal (`channel_sessions.metadata.ai_gate = 'allowlist'`):
|
||||
-- ausente / 'open' = comportamento de hoje (a IA responde todo inbound quando há
|
||||
-- agente publicado), nenhum self-hoster afetado. Com 'allowlist', a IA só
|
||||
-- responde quando o CONTATO está autorizado, e é isto que estas colunas guardam.
|
||||
-- Contact-level como `force_human` (a trava oposta). Aditiva e idempotente:
|
||||
-- colunas anuláveis, sem default, sem constraint — nenhuma linha existente viola
|
||||
-- nada. RLS de `contacts` já cobre (row-level); coluna nova não precisa policy.
|
||||
|
||||
alter table public.contacts
|
||||
add column if not exists ai_authorized_at timestamptz;
|
||||
|
||||
alter table public.contacts
|
||||
add column if not exists ai_authorized_reason text;
|
||||
|
||||
comment on column public.contacts.ai_authorized_at is
|
||||
'Elegibilidade da IA (gate opt-in channel_sessions.metadata.ai_gate=allowlist): quando o contato foi autorizado a ser atendido automaticamente. NULL = não autorizado, a IA não responde. Renovado a cada turno autorizado enquanto a conversa está viva.';
|
||||
|
||||
comment on column public.contacts.ai_authorized_reason is
|
||||
'Origem da autorização de IA: respondi:<form>:<submission> | campanha:<id> | automacao:<rule> | retomada_manual.';
|
||||
|
||||
notify pgrst, 'reload schema';
|
||||
|
||||
-- ---- VARREDURA anon: função nova nasce exposta em quem ATUALIZA (migration 0116) ----
|
||||
--
|
||||
-- ⚠️ ESTE BLOCO É, DE PROPÓSITO, O ÚLTIMO DO ARQUIVO. Apêndice novo entra ANTES
|
||||
@@ -17300,36 +17357,3 @@ grant execute on function public.fn_decrypt_oauth(bytea) to service_role;
|
||||
grant execute on function public.fn_encrypt_oauth(text) to service_role;
|
||||
grant execute on function public.fn_lgpd_cascade_redact_contact(uuid, uuid, uuid) to service_role;
|
||||
grant execute on function public.fn_update_budget_consumption() to service_role;
|
||||
|
||||
-- ---- versão de acervo conta por MATERIAL, não por agente (migration 0205) ----
|
||||
--
|
||||
-- O índice `ai_kbv_version_unique` era `(agent_id, version_number)`, mas desde a
|
||||
-- 0181 o número é contado por `knowledge_source_id`. Toda fonte nova nasce com
|
||||
-- `version_number = 1`, então a SEGUNDA fonte do mesmo agente colidia com a
|
||||
-- primeira e nunca indexava — a tela dizia "pronto" e `chunks_count` ficava 0.
|
||||
-- Determinístico, não corrida. Medido em produção: 5 materiais, 1 indexou.
|
||||
--
|
||||
-- Dois índices parciais porque há dois regimes: versões anteriores à 0181 têm
|
||||
-- `knowledge_source_id` NULL e guardam o invariante antigo (por agente); sem o
|
||||
-- segundo índice elas ficariam sem restrição, já que NULL não colide com NULL.
|
||||
delete from public.ai_knowledge_versions v
|
||||
where v.knowledge_source_id is not null
|
||||
and exists (
|
||||
select 1 from public.ai_knowledge_versions o
|
||||
where o.knowledge_source_id = v.knowledge_source_id
|
||||
and o.version_number = v.version_number
|
||||
and o.id < v.id
|
||||
);
|
||||
|
||||
alter table public.ai_knowledge_versions
|
||||
drop constraint if exists ai_kbv_version_unique;
|
||||
|
||||
drop index if exists public.ai_kbv_version_unique;
|
||||
|
||||
create unique index if not exists ai_kbv_version_por_fonte
|
||||
on public.ai_knowledge_versions (knowledge_source_id, version_number)
|
||||
where knowledge_source_id is not null;
|
||||
|
||||
create unique index if not exists ai_kbv_version_por_agente_legado
|
||||
on public.ai_knowledge_versions (agent_id, version_number)
|
||||
where knowledge_source_id is null;
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
-- 0206 · Elegibilidade da IA por ORIGEM do lead — o "deny by default" por canal.
|
||||
--
|
||||
-- ## O defeito
|
||||
--
|
||||
-- O DeskcommCRM responde `allow by default`: publicou agente para a sessão de
|
||||
-- WhatsApp, a IA atende TODO mundo que mandar mensagem — não há gate de
|
||||
-- elegibilidade em lugar nenhum. `lib/channels/pos-entrada.ts` emite
|
||||
-- `ai_agent.dispatch_requested` para todo inbound novo; `lib/agent-engine/edge/
|
||||
-- crm/drain.ts` só checa se existe agente publicado para a sessão. Num número
|
||||
-- que também é o WhatsApp pessoal/comercial do dono — clientes atuais,
|
||||
-- fornecedores, contatos pessoais, conversas antigas —, isso é a IA assumindo
|
||||
-- conversa que era de gente.
|
||||
--
|
||||
-- ## A correção
|
||||
--
|
||||
-- Gate OPT-IN por canal: `channel_sessions.metadata.ai_gate = 'allowlist'`
|
||||
-- (ausente / `'open'` = comportamento de hoje, nenhum self-hoster afetado). Com
|
||||
-- o gate ligado, a IA só responde quando o CONTATO está explicitamente
|
||||
-- autorizado — e é isso que estas duas colunas guardam.
|
||||
--
|
||||
-- ai_authorized_at quando a autorização foi concedida (NULL = não autorizado)
|
||||
-- ai_authorized_reason de onde veio: 'respondi:<form>:<submission>',
|
||||
-- 'campanha:<id>', 'automacao:<rule>', 'retomada_manual'
|
||||
--
|
||||
-- Contact-level, como `force_human` (a trava oposta): a elegibilidade é sobre
|
||||
-- "esta pessoa é um lead que podemos abordar automaticamente", não sobre um
|
||||
-- thread. Uma janela de validade (`AI_ALLOWLIST_TTL_DAYS`, default 21) impede
|
||||
-- que submissão antiga reative a IA meses depois — o turno autorizado renova o
|
||||
-- carimbo enquanto a conversa está viva.
|
||||
--
|
||||
-- Aditiva e idempotente: colunas anuláveis, sem default, sem constraint. Nenhuma
|
||||
-- linha existente passa a violar nada; o `update.sh` de um clone não quebra. RLS
|
||||
-- é row-level e já cobre `contacts` (`tenant_isolation_contacts_all`) — coluna
|
||||
-- nova não precisa de policy.
|
||||
|
||||
alter table public.contacts
|
||||
add column if not exists ai_authorized_at timestamptz;
|
||||
|
||||
alter table public.contacts
|
||||
add column if not exists ai_authorized_reason text;
|
||||
|
||||
comment on column public.contacts.ai_authorized_at is
|
||||
'Elegibilidade da IA (gate opt-in channel_sessions.metadata.ai_gate=allowlist): quando o contato foi autorizado a ser atendido automaticamente. NULL = não autorizado, a IA não responde. Renovado a cada turno autorizado enquanto a conversa está viva.';
|
||||
|
||||
comment on column public.contacts.ai_authorized_reason is
|
||||
'Origem da autorização de IA: respondi:<form>:<submission> | campanha:<id> | automacao:<rule> | retomada_manual.';
|
||||
|
||||
notify pgrst, 'reload schema';
|
||||
@@ -233,6 +233,7 @@ aplica.
|
||||
| `20260831000000` | `0203_comando_da_conversa` | O banco passa a saber QUEM MANDA na conversa. `fn_comando_da_conversa` (a regra, `immutable`, com `p_agora` parametrizado para o teste de espelho ter relógio fixo) e `comando_da_conversa(conversations)` (campo calculado que o PostgREST publica em `?select=` e em `?comando_da_conversa=in.(...)`). PORQUÊ: as abas da Inbox descreviam quem manda lendo `conversations.status`, coluna que o motor de IA nunca lê — medido na VPS, a aba IA mostrava 2 e a Fila 83 enquanto o motor atendia 49. O filtro precisa de `contacts.force_human`/`is_blocked` (outra tabela), então reescrevê-lo no query builder seria a regra em duas encarnações. Casadas por `tests/invariants/comando-da-conversa-espelha-o-ts.test.ts`. Invoker de propósito (respeita RLS de quem pergunta), e por isso a varredura anon do fim do baseline não as alcança — revoke/grant explícitos. |
|
||||
| `20260901120000` | `0204_catalogo_de_produtos_da_loja` | **O catálogo que a LOJA possui — a tabela que faltava para o agente de IA responder preço EXATO.** Até aqui a única tabela de produto do schema era `nuvemshop_products`, que é ESPELHO de loja remota: medido, ela não tem UM escritor no repositório inteiro e o indexador do RAG nem a lê (vai à API da Nuvemshop direto). Uma loja física sem Nuvemshop não tinha onde guardar preço como DADO — sobrava o RAG, e preço em busca semântica é armadilha conhecida: o trecho de 'iPhone 15 Pro 256GB' casa com o do 'iPhone 15 128GB' e o agente responde o valor errado. **Tabela NOVA e não `nuvemshop_products` + coluna `origem`, por quatro razões em ordem de força:** (1) a policy daquela tabela é `for all` org-flat sem `fn_role_at_least` — qualquer `viewer` reescreve preço pelo PostgREST com o JWT dele —, e ela está congelada na allowlist de dívida de RBAC, que é fechada para tabela nova; (2) `external_id`, `last_updated_at` e `payload` são `not null` de espelho, e produto digitado à mão teria de inventar os três, com `unique(org, external_id)` impondo chave falsa; (3) dois donos de escrita na mesma tabela — no dia em que o sync ganhar o escritor que lhe falta, um `where origem = 'nuvemshop'` esquecido apaga o catálogo digitado à mão; (4) o nome mente em instalação sem Nuvemshop. **Uma linha por item VENDÁVEL**, não pai+variações: quem tem preço é o SKU, e um pai sem preço não responde ao cliente (DIRC letra C — o 'modelo' é derivável de marca+categoria+nome). `controla_estoque` conserta uma armadilha da tool antiga, que filtra `available_qty > 0` por default e deixaria invisível o catálogo de quem não conta estoque (decant, sob encomenda). RLS no molde da 0177: leitura para a org, escrita só de `manager` para cima. Índice GIN trigram em `nome` para a parte difusa da busca por token (ver `lib/catalogo/busca.ts`). |
|
||||
| `20260902020000` | `0205_um_agente_indexa_mais_de_um_material` | **Um agente só conseguia ter UM material indexado — para sempre, e em silêncio.** A migration 0181 mudou a semântica do número da versão de acervo: passou a ser "a quantas indexações DESTE material", contado por `knowledge_source_id` — é o que `lib/ai/rag/version.ts` faz e o que o comentário dele diz. O índice único não acompanhou: seguiu em `(agent_id, version_number)`. Como toda fonte nova nasce com `version_number = 1`, a SEGUNDA fonte do mesmo agente colidia com a primeira. **Não é corrida, é determinístico.** Medido em produção: cinco materiais subidos para o mesmo agente, um indexou com 14 trechos e quatro pararam em `pending` com `attempts=2`; a tela mostrava os cinco como `ready`, com `chunks_count = 0`. **Dois índices parciais e não um**: as versões anteriores à 0181 têm `knowledge_source_id` NULL e continuam válidas, e um índice puro por fonte as deixaria SEM restrição nenhuma (em Postgres NULL não colide com NULL) — o que afrouxaria um invariante que hoje vale. Cada regime guarda o seu. A dedup vem ANTES do índice porque um clone com duplicata do regime novo quebraria o `update.sh` no meio. |
|
||||
| `20260903000000` | `0206_contact_ai_authorization` | **A IA respondia `allow by default`: publicou agente para a sessão de WhatsApp, ela atendia TODO mundo.** (Renumerada duas vezes na fila da `main`: 0202 → 0203 → **0206**, porque `0203_comando_da_conversa`, `0204_catalogo_de_produtos_da_loja` e `0205_um_agente_indexa_mais_de_um_material` entraram antes.) Não há gate de elegibilidade — `lib/channels/pos-entrada.ts` emite `ai_agent.dispatch_requested` para todo inbound novo, `lib/agent-engine/edge/crm/drain.ts` só checa se existe agente publicado para a sessão, e `resolve-turn-agent.ts` sempre resolve alguém ("silêncio não é desfecho possível", regra 5). Num número que é também o WhatsApp pessoal/comercial do dono, a IA assumia conversa de cliente atual, fornecedor, contato pessoal e conversa antiga. Correção: gate OPT-IN por canal (`channel_sessions.metadata.ai_gate = 'allowlist'`; ausente/`'open'` = comportamento de hoje, zero self-hoster afetado). Com o gate, a IA só responde quando o CONTATO está autorizado — `ai_authorized_at` (NULL = não autorizado) + `ai_authorized_reason` (`respondi:<form>:<submission>` \| `campanha:<id>` \| `automacao:<rule>` \| `retomada_manual`). Contact-level como `force_human`, a trava oposta. Janela de validade (`AI_ALLOWLIST_TTL_DAYS`, default 21) impede submissão antiga reativar a IA; o turno autorizado renova o carimbo enquanto a conversa está viva. Aditiva e idempotente: colunas anuláveis, sem default, sem constraint — nenhuma linha existente viola nada, `update.sh` de clone não quebra. Anti-backlog no mesmo PR (sem schema): o drain pula evento cuja mensagem-gatilho já foi superada por inbound mais recente — vale para toda instalação, conserta "reiniciar o worker dispara histórico". |
|
||||
|
||||
## Reproducibility
|
||||
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* J20.18 — EU RESPONDO O CLIENTE À MÃO PELO WHATSAPP, E A IA PARA.
|
||||
*
|
||||
* Numa conversa que o gate autorizou (o lead veio de uma origem elegível), o
|
||||
* dono pega o celular e responde o cliente direto no WhatsApp. Essa mensagem
|
||||
* entra pelo webhook do provider (`fromMe=true`, sem passar pelo composer do
|
||||
* CRM). A IA tem de PARAR nessa conversa — silêncio DURÁVEL
|
||||
* (`bot_silenced_until='infinity'` + rastro de handoff) — SEM apagar
|
||||
* `contacts.ai_authorized_at`: a origem do lead é estado separado da pausa.
|
||||
* A volta é explícita, pela tela ("devolver ao automático").
|
||||
*
|
||||
* O que este spec prova, e por qual observável:
|
||||
* - o webhook `fromMe=true` genuíno pausa a IA → `conversations.bot_silenced_until`
|
||||
* - a tela DIZ que uma pessoa assumiu → badge de atendimento humano
|
||||
* - a autorização do lead SOBREVIVE à pausa → `contacts.ai_authorized_at`
|
||||
* - a pausa é idempotente (2ª mensagem não re-carimba) → `last_handoff_at` estável
|
||||
* - "devolver ao automático" solta a trava → `bot_silenced_until` volta a null
|
||||
* …e a autorização CONTINUA lá → `contacts.ai_authorized_at`
|
||||
*
|
||||
* Pré-requisitos (banco local estilo VPS, app buildada):
|
||||
* npx tsx scripts/seed-e2e-credentials.ts
|
||||
* npx tsx scripts/seed-e2e-elegibilidade.ts
|
||||
* pnpm e2e:env && pnpm e2e:build
|
||||
* E2E_PORT=3001 pnpm exec playwright test tests/e2e/j20-elegibilidade-atendimento-manual.spec.ts
|
||||
*/
|
||||
import { execFileSync } from "node:child_process";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
const APP_URL = `http://localhost:${process.env.E2E_PORT ?? "3001"}`;
|
||||
const CREDS_PATH = path.join(process.cwd(), ".e2e-creds.json");
|
||||
const EVIDENCIA = path.join(process.cwd(), ".superpowers/evidence/j20-elegibilidade");
|
||||
|
||||
interface Creds {
|
||||
password: string;
|
||||
org_id: string;
|
||||
users: Record<string, { email: string }>;
|
||||
elegibilidade?: { channel_session_id: string; waha_path_token: string };
|
||||
}
|
||||
|
||||
let creds: Creds;
|
||||
|
||||
function seedBase(): void {
|
||||
if (!fs.existsSync(CREDS_PATH)) {
|
||||
execFileSync("npx", ["tsx", "scripts/seed-e2e-credentials.ts"], { stdio: "inherit" });
|
||||
}
|
||||
const atual = JSON.parse(fs.readFileSync(CREDS_PATH, "utf8")) as Creds;
|
||||
if (!atual.elegibilidade) {
|
||||
execFileSync("npx", ["tsx", "scripts/seed-e2e-elegibilidade.ts"], { stdio: "inherit" });
|
||||
}
|
||||
creds = JSON.parse(fs.readFileSync(CREDS_PATH, "utf8")) as Creds;
|
||||
if (!creds.elegibilidade) {
|
||||
throw new Error("bloco `elegibilidade` ausente após seed — veja a saída de seed-e2e-elegibilidade.ts");
|
||||
}
|
||||
}
|
||||
|
||||
/** Roda 1 subcomando do helper de SQL cru e devolve o JSON da última linha. */
|
||||
function helper<T = unknown>(...args: string[]): T {
|
||||
const stdout = execFileSync("npx", ["tsx", "scripts/e2e-elegibilidade-helpers.ts", ...args], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
const last = stdout.trim().split("\n").filter(Boolean).pop();
|
||||
if (!last) throw new Error(`helper ${args[0]} não imprimiu JSON`);
|
||||
return JSON.parse(last) as T;
|
||||
}
|
||||
|
||||
async function login(page: Page, email: string): Promise<void> {
|
||||
await page.goto(`${APP_URL}/login`);
|
||||
await page.locator("#email").fill(email);
|
||||
await page.locator("#password").fill(creds.password);
|
||||
await page.getByRole("button", { name: /entrar/i }).click();
|
||||
await page.waitForURL(/\/app\//);
|
||||
}
|
||||
|
||||
async function captura(page: Page, nome: string): Promise<void> {
|
||||
fs.mkdirSync(EVIDENCIA, { recursive: true });
|
||||
await page.screenshot({ path: path.join(EVIDENCIA, `${nome}.png`), fullPage: true });
|
||||
}
|
||||
|
||||
/** POST no webhook per-tenant do WAHA (mesma rota que o WAHA real chama; sem
|
||||
* assinatura — `.env.e2e` não liga `WAHA_WEBHOOK_REQUIRE_SIGNATURE`). */
|
||||
async function postWaha(payload: unknown): Promise<number> {
|
||||
const token = creds.elegibilidade!.waha_path_token;
|
||||
const res = await fetch(`${APP_URL}/api/v1/webhooks/waha/${token}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
return res.status;
|
||||
}
|
||||
|
||||
async function esperarContatoPorTelefone(digits: string): Promise<string> {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const c = helper<{ id: string } | null>("find-contact-by-phone", digits);
|
||||
if (c?.id) return c.id;
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
throw new Error(`contato do telefone ${digits} não apareceu em 10s`);
|
||||
}
|
||||
|
||||
test.describe("J20.18 — resposta manual pelo celular pausa a IA (sem apagar a autorização)", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
test.use({ actionTimeout: 15_000 });
|
||||
|
||||
test.beforeAll(() => {
|
||||
seedBase();
|
||||
});
|
||||
|
||||
test("conversa autorizada → mensagem fromMe genuína pausa a IA; autorização sobrevive; a volta é pela tela", async ({
|
||||
page,
|
||||
}) => {
|
||||
const session = `e2e-elegibilidade-session`;
|
||||
// Telefone do cliente — E.164 sem o `+` vira o chatId `@c.us`.
|
||||
const digits = `55119${String(Date.now()).slice(-8)}`;
|
||||
const chatId = `${digits}@c.us`;
|
||||
let contactId = "";
|
||||
|
||||
try {
|
||||
// ---------------------------------------------------------------------
|
||||
// (1) O cliente manda uma mensagem — a ingestão real cria contato+conversa.
|
||||
// ---------------------------------------------------------------------
|
||||
expect(
|
||||
await postWaha({
|
||||
event: "message",
|
||||
session,
|
||||
payload: {
|
||||
id: `false_${digits}@c.us_J20MANUAL${Date.now()}`,
|
||||
from: chatId,
|
||||
fromMe: false,
|
||||
body: "Oi, vi o formulário de vocês. Podem me passar os valores?",
|
||||
type: "chat",
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
_data: { notifyName: "Cliente J20.18" },
|
||||
},
|
||||
}),
|
||||
).toBe(200);
|
||||
|
||||
contactId = await esperarContatoPorTelefone(digits);
|
||||
|
||||
// A origem elegível carimba a autorização (aqui, direto — o caso sob teste
|
||||
// não é o webhook do Respondi, é a resposta manual).
|
||||
expect(helper<{ ok: boolean }>("set-authorized", contactId).ok).toBe(true);
|
||||
|
||||
const conv = helper<{ id: string; bot_silenced_until: string | null }>(
|
||||
"conversation-for-contact",
|
||||
contactId,
|
||||
);
|
||||
const conversationId = conv.id;
|
||||
expect(conv.bot_silenced_until, "estado de partida: sem silêncio").toBeNull();
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// (2) O dono responde pelo celular — webhook fromMe=true, sem composer.
|
||||
// ---------------------------------------------------------------------
|
||||
expect(
|
||||
await postWaha({
|
||||
event: "message.any",
|
||||
session,
|
||||
payload: {
|
||||
id: `true_${digits}@c.us_J20REPLY${Date.now()}`,
|
||||
to: chatId,
|
||||
fromMe: true,
|
||||
body: "Oi! Já te passo os valores por aqui.",
|
||||
type: "text",
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
},
|
||||
}),
|
||||
).toBe(200);
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// (3) A PROVA QUE IMPORTA: a IA parou de verdade nesta conversa.
|
||||
// ---------------------------------------------------------------------
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
helper<{ bot_silenced_until: string | null }>("conversation-silence", conversationId)
|
||||
.bot_silenced_until,
|
||||
{ timeout: 20_000, message: "a resposta manual tem de silenciar a IA de forma durável" },
|
||||
)
|
||||
.toMatch(/infinity/i);
|
||||
|
||||
const depoisDaPausa = helper<{
|
||||
bot_silenced_until: string | null;
|
||||
last_handoff_at: string | null;
|
||||
last_handoff_reason: string | null;
|
||||
}>("conversation-silence", conversationId);
|
||||
expect(String(depoisDaPausa.last_handoff_reason)).toMatch(/manual/i);
|
||||
expect(depoisDaPausa.last_handoff_at).not.toBeNull();
|
||||
|
||||
// A AUTORIZAÇÃO DO LEAD SOBREVIVE — pausar a conversa ≠ apagar a origem.
|
||||
const autorizacao = helper<{ ai_authorized_at: string | null; ai_authorized_reason: string | null }>(
|
||||
"contact-authorization",
|
||||
contactId,
|
||||
);
|
||||
expect(
|
||||
autorizacao.ai_authorized_at,
|
||||
"a resposta manual NÃO pode apagar ai_authorized_at — é estado separado",
|
||||
).not.toBeNull();
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// (4) Idempotência: uma 2ª mensagem do celular não re-carimba o handoff.
|
||||
// ---------------------------------------------------------------------
|
||||
const handoffAntes = depoisDaPausa.last_handoff_at;
|
||||
expect(
|
||||
await postWaha({
|
||||
event: "message.any",
|
||||
session,
|
||||
payload: {
|
||||
id: `true_${digits}@c.us_J20REPLY2${Date.now()}`,
|
||||
to: chatId,
|
||||
fromMe: true,
|
||||
body: "Segue a tabela.",
|
||||
type: "text",
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
},
|
||||
}),
|
||||
).toBe(200);
|
||||
await page.waitForTimeout(1_500);
|
||||
expect(
|
||||
helper<{ last_handoff_at: string | null }>("conversation-silence", conversationId).last_handoff_at,
|
||||
"conversa já silenciada no futuro não re-carimba o handoff",
|
||||
).toBe(handoffAntes);
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// (5) A tela DIZ que uma pessoa assumiu, e oferece a volta.
|
||||
// ---------------------------------------------------------------------
|
||||
await login(page, creds.users.agent!.email);
|
||||
await page.goto(`${APP_URL}/app/inbox/${conversationId}`);
|
||||
await expect(
|
||||
page.getByTestId("badge-atendimento-humano"),
|
||||
"conversa com a IA pausada não pode ter a mesma cara de uma normal",
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
const devolver = page.getByTestId("devolver-ao-automatico");
|
||||
await expect(devolver).toBeVisible();
|
||||
await captura(page, "18-ia-pausada-por-resposta-manual");
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// (6) A volta: solta a trava — e a autorização CONTINUA lá.
|
||||
// ---------------------------------------------------------------------
|
||||
await devolver.click();
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
helper<{ bot_silenced_until: string | null }>("conversation-silence", conversationId)
|
||||
.bot_silenced_until,
|
||||
{ timeout: 20_000 },
|
||||
)
|
||||
.toBeNull();
|
||||
await expect(page.getByTestId("badge-atendimento-humano")).toHaveCount(0, { timeout: 20_000 });
|
||||
await captura(page, "18-devolvido-ao-automatico");
|
||||
|
||||
expect(
|
||||
helper<{ ai_authorized_at: string | null }>("contact-authorization", contactId).ai_authorized_at,
|
||||
"devolver ao automático não pode apagar a origem do lead",
|
||||
).not.toBeNull();
|
||||
} finally {
|
||||
if (contactId) {
|
||||
try {
|
||||
helper("cleanup-contact", contactId);
|
||||
} catch (e) {
|
||||
console.error("[cleanup] falhou (não mascara o teste):", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* J20.12 — O FOLLOW-UP AUTOMÁTICO RESPEITA O GATE.
|
||||
*
|
||||
* Num canal com o gate LIGADO (`channel_sessions.metadata.ai_gate='allowlist'`),
|
||||
* a varredura de silêncio (`lib/followup/silence-sweep.ts`, dentro do cron
|
||||
* `followup-flow-worker`) só inscreve um contato silencioso se ele estiver
|
||||
* AUTORIZADO — `loadSilentContactIds` pula `gateAllowlist && !autorizado`. Um
|
||||
* cliente atual que nunca passou por origem elegível NÃO é enrolado: a IA não
|
||||
* "enrola" quem ela não deveria atender.
|
||||
*
|
||||
* O que este spec prova, e por qual observável:
|
||||
* - contato silencioso AUTORIZADO → nasce linha em `followup_enrollments`
|
||||
* - contato silencioso NÃO autorizado → NENHUM enrollment (mesmo silêncio, mesmo canal)
|
||||
*
|
||||
* Tudo pela API REAL depois do login (mesmo caminho de produção que
|
||||
* followup-journey.spec.ts): cria o fluxo, publica, vincula a um agente
|
||||
* publicado com follow-up habilitado, e roda o cron de verdade.
|
||||
*
|
||||
* Pré-requisitos (banco local estilo VPS, app buildada):
|
||||
* npx tsx scripts/seed-e2e-credentials.ts
|
||||
* npx tsx scripts/seed-e2e-elegibilidade.ts
|
||||
* pnpm e2e:env && pnpm e2e:build
|
||||
* E2E_PORT=3001 pnpm exec playwright test tests/e2e/j20-elegibilidade-followup.spec.ts
|
||||
*/
|
||||
import { execFileSync } from "node:child_process";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
import { carregarEnvLocal } from "../../scripts/lib/env-de-teste";
|
||||
|
||||
const APP_URL = `http://localhost:${process.env.E2E_PORT ?? "3001"}`;
|
||||
const CREDS_PATH = path.join(process.cwd(), ".e2e-creds.json");
|
||||
|
||||
const THRESHOLD_MIN = 5;
|
||||
|
||||
interface Creds {
|
||||
password: string;
|
||||
org_id: string;
|
||||
users: Record<string, { email: string }>;
|
||||
elegibilidade?: { channel_session_id: string; credential_id: string };
|
||||
}
|
||||
|
||||
let creds: Creds;
|
||||
|
||||
function seedBase(): void {
|
||||
if (!fs.existsSync(CREDS_PATH)) {
|
||||
execFileSync("npx", ["tsx", "scripts/seed-e2e-credentials.ts"], { stdio: "inherit" });
|
||||
}
|
||||
const atual = JSON.parse(fs.readFileSync(CREDS_PATH, "utf8")) as Creds;
|
||||
if (!atual.elegibilidade) {
|
||||
execFileSync("npx", ["tsx", "scripts/seed-e2e-elegibilidade.ts"], { stdio: "inherit" });
|
||||
}
|
||||
creds = JSON.parse(fs.readFileSync(CREDS_PATH, "utf8")) as Creds;
|
||||
if (!creds.elegibilidade) {
|
||||
throw new Error("bloco `elegibilidade` ausente após seed — veja a saída de seed-e2e-elegibilidade.ts");
|
||||
}
|
||||
}
|
||||
|
||||
function internalSecret(): string {
|
||||
const s = carregarEnvLocal().INTERNAL_SECRET?.trim();
|
||||
if (!s) throw new Error("INTERNAL_SECRET ausente no ambiente (.env.e2e / .env.local)");
|
||||
return s;
|
||||
}
|
||||
|
||||
function helper<T = unknown>(...args: string[]): T {
|
||||
const stdout = execFileSync("npx", ["tsx", "scripts/e2e-elegibilidade-helpers.ts", ...args], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
const last = stdout.trim().split("\n").filter(Boolean).pop();
|
||||
if (!last) throw new Error(`helper ${args[0]} não imprimiu JSON`);
|
||||
return JSON.parse(last) as T;
|
||||
}
|
||||
|
||||
async function login(page: Page, email: string): Promise<void> {
|
||||
await page.goto(`${APP_URL}/login`);
|
||||
await page.locator("#email").fill(email);
|
||||
await page.locator("#password").fill(creds.password);
|
||||
await page.getByRole("button", { name: /entrar/i }).click();
|
||||
await page.waitForURL(/\/app\//);
|
||||
}
|
||||
|
||||
/** Grafo mínimo publicável: trigger → wait(5min) → ação(ai_message) → fim.
|
||||
* Espelha a fixture verde de `lib/followup/validate-publish.test.ts`. */
|
||||
function grafoMinimo(): unknown {
|
||||
const pos = (y: number) => ({ x: 0, y });
|
||||
return {
|
||||
nodes: [
|
||||
{ id: "t1", type: "trigger", label: "Início", position: pos(0), config: {} },
|
||||
{ id: "w1", type: "wait", label: "Espera", position: pos(120), config: { mode: "fixed", duration_ms: 300_000 } },
|
||||
{
|
||||
id: "a1",
|
||||
type: "action",
|
||||
label: "Mensagem",
|
||||
position: pos(240),
|
||||
config: { mode: "ai_message", prompt_hint: "Pergunte com simpatia se ainda há interesse." },
|
||||
},
|
||||
{ id: "end1", type: "end", label: "Fim", position: pos(360), config: { outcome: "exhausted" } },
|
||||
],
|
||||
edges: [
|
||||
{ id: "edge1", source: "t1", target: "w1", priority: 0, condition: { type: "always" } },
|
||||
{ id: "edge2", source: "w1", target: "a1", priority: 0, condition: { type: "always" } },
|
||||
{ id: "edge3", source: "a1", target: "end1", priority: 0, condition: { type: "always" } },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function publicarFluxoDeSilencio(page: Page): Promise<string> {
|
||||
const criar = await page.request.post(`${APP_URL}/api/v1/ai/followup-flows`, {
|
||||
data: { name: `E2E Silêncio Elegibilidade ${Date.now()}` },
|
||||
});
|
||||
expect(criar.status(), await criar.text()).toBe(201);
|
||||
const pointerId = ((await criar.json()) as { data: { id: string } }).data.id;
|
||||
|
||||
const patch = await page.request.patch(`${APP_URL}/api/v1/ai/followup-flows/${pointerId}`, {
|
||||
data: {
|
||||
draft_graph: grafoMinimo(),
|
||||
trigger_config: { kind: "silence", params: { threshold_minutes: THRESHOLD_MIN } },
|
||||
},
|
||||
});
|
||||
expect(patch.status(), await patch.text()).toBe(200);
|
||||
|
||||
const publicar = await page.request.post(`${APP_URL}/api/v1/ai/followup-flows/${pointerId}/publish`);
|
||||
expect(publicar.status(), await publicar.text()).toBe(200);
|
||||
return pointerId;
|
||||
}
|
||||
|
||||
async function rodarCronDeFollowup(): Promise<void> {
|
||||
const res = await fetch(`${APP_URL}/api/v1/cron/followup-flow-worker`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${internalSecret()}` },
|
||||
});
|
||||
expect(res.ok, `cron followup-flow-worker: ${res.status}`).toBeTruthy();
|
||||
}
|
||||
|
||||
test.describe("J20.12 — o follow-up automático respeita o gate", () => {
|
||||
test.describe.configure({ timeout: 240_000 });
|
||||
test.use({ actionTimeout: 15_000 });
|
||||
|
||||
test.beforeAll(() => {
|
||||
seedBase();
|
||||
});
|
||||
|
||||
test("silencioso autorizado → enrola; silencioso NÃO autorizado → não enrola", async ({ page }) => {
|
||||
let pointerId = "";
|
||||
let agentId = "";
|
||||
let autorizadoId = "";
|
||||
let semAutorizacaoId = "";
|
||||
|
||||
try {
|
||||
await login(page, creds.users.manager!.email);
|
||||
|
||||
// (1) Fluxo de silêncio publicado (API real) + agente publicado que o ARMA.
|
||||
// O agente é SETUP via helper (o `POST /api/v1/ai/agents` exige role
|
||||
// `admin`/MFA; o que está sob teste é a varredura, não a criação do agente).
|
||||
pointerId = await publicarFluxoDeSilencio(page);
|
||||
agentId = helper<{ agentId: string }>("publish-agent", pointerId).agentId;
|
||||
|
||||
// (2) Dois contatos silenciosos no MESMO canal com gate — um de cada lado.
|
||||
const a = helper<{ contactId: string }>("seed-silent-contact", "1", String(THRESHOLD_MIN));
|
||||
const b = helper<{ contactId: string }>("seed-silent-contact", "0", String(THRESHOLD_MIN));
|
||||
autorizadoId = a.contactId;
|
||||
semAutorizacaoId = b.contactId;
|
||||
|
||||
// (3) Roda o cron de verdade — a varredura de silêncio decide quem entra.
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await rodarCronDeFollowup();
|
||||
await page.waitForTimeout(800);
|
||||
}
|
||||
|
||||
// (4) O autorizado ENTROU; o não autorizado NÃO — mesmo silêncio, mesmo canal.
|
||||
await expect
|
||||
.poll(() => helper<{ id: string } | null>("enrollment-for-contact", autorizadoId), {
|
||||
timeout: 20_000,
|
||||
message: "contato silencioso AUTORIZADO tem de ser enrolado pela varredura",
|
||||
})
|
||||
.not.toBeNull();
|
||||
|
||||
expect(
|
||||
helper<{ id: string } | null>("enrollment-for-contact", semAutorizacaoId),
|
||||
"contato silencioso NÃO autorizado, no canal com gate: a varredura NÃO enrola",
|
||||
).toBeNull();
|
||||
} finally {
|
||||
for (const id of [autorizadoId, semAutorizacaoId]) {
|
||||
if (id) {
|
||||
try {
|
||||
helper("cleanup-contact", id);
|
||||
} catch (e) {
|
||||
console.error("[cleanup] contato falhou:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (pointerId) {
|
||||
try {
|
||||
helper("cleanup-flow", pointerId);
|
||||
} catch (e) {
|
||||
console.error("[cleanup] cleanup-flow falhou:", e);
|
||||
}
|
||||
}
|
||||
if (agentId) {
|
||||
try {
|
||||
helper("archive-agent", agentId);
|
||||
} catch (e) {
|
||||
console.error("[cleanup] archive-agent falhou:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* J20.6 — UMA NOVA SUBMISSÃO DO RESPONDI AUTORIZA A IA (e o retorno do lead
|
||||
* pelo WhatsApp é atendido).
|
||||
*
|
||||
* Num canal com o gate LIGADO (`channel_sessions.metadata.ai_gate='allowlist'`),
|
||||
* a IA só assume um contato que uma ORIGEM ELEGÍVEL autorizou. Uma submissão do
|
||||
* Respondi é uma dessas origens: o webhook `POST /api/v1/webhooks/in/:token`
|
||||
* carimba `contacts.ai_authorized_at` + `ai_authorized_reason='respondi:<form>:<sub>'`.
|
||||
* Depois disso, quando o lead responder pelo WhatsApp, o drain do agent-engine
|
||||
* ENFILEIRA o turno em vez de pular.
|
||||
*
|
||||
* O que este spec prova, e por qual observável:
|
||||
* - a submissão do Respondi autoriza o contato → `contacts.ai_authorized_at` / `_reason`
|
||||
* - o retorno do lead autorizado gera turno → linha em `job_queue` (kind `inbound_turn`)
|
||||
* - CONTROLE — um número que NÃO passou pelo → `event_log` vira `done`, SEM job
|
||||
* Respondi, no mesmo canal, não gera turno
|
||||
*
|
||||
* O drain do agent-engine roda no `workers/agent-worker` (processo 24/7) em
|
||||
* produção; a suíte E2E não sobe worker, então o tick é chamado pela MESMA
|
||||
* função (`drainTick`) via `scripts/e2e-elegibilidade-helpers.ts drain-once`.
|
||||
*
|
||||
* Pré-requisitos (banco local estilo VPS, app buildada):
|
||||
* npx tsx scripts/seed-e2e-credentials.ts
|
||||
* npx tsx scripts/seed-e2e-elegibilidade.ts
|
||||
* pnpm e2e:env && pnpm e2e:build
|
||||
* E2E_PORT=3001 pnpm exec playwright test tests/e2e/j20-elegibilidade-respondi.spec.ts
|
||||
*/
|
||||
import { execFileSync } from "node:child_process";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
const APP_URL = `http://localhost:${process.env.E2E_PORT ?? "3001"}`;
|
||||
const CREDS_PATH = path.join(process.cwd(), ".e2e-creds.json");
|
||||
const RESPONDI_FIXTURE = path.join(process.cwd(), "tests/fixtures/webhooks/respondi-imobiliario.json");
|
||||
const EVIDENCIA = path.join(process.cwd(), ".superpowers/evidence/j20-elegibilidade");
|
||||
|
||||
const RESPONDI_PHONE_ALIAS = "Qual é o melhor WhatsApp para falarmos sobre essa análise?";
|
||||
|
||||
interface Creds {
|
||||
password: string;
|
||||
org_id: string;
|
||||
users: Record<string, { email: string }>;
|
||||
elegibilidade?: {
|
||||
channel_session_id: string;
|
||||
waha_path_token: string;
|
||||
credential_id: string;
|
||||
webhook_source_token: string;
|
||||
};
|
||||
}
|
||||
|
||||
let creds: Creds;
|
||||
|
||||
function seedBase(): void {
|
||||
if (!fs.existsSync(CREDS_PATH)) {
|
||||
execFileSync("npx", ["tsx", "scripts/seed-e2e-credentials.ts"], { stdio: "inherit" });
|
||||
}
|
||||
const atual = JSON.parse(fs.readFileSync(CREDS_PATH, "utf8")) as Creds;
|
||||
if (!atual.elegibilidade) {
|
||||
execFileSync("npx", ["tsx", "scripts/seed-e2e-elegibilidade.ts"], { stdio: "inherit" });
|
||||
}
|
||||
creds = JSON.parse(fs.readFileSync(CREDS_PATH, "utf8")) as Creds;
|
||||
if (!creds.elegibilidade) {
|
||||
throw new Error("bloco `elegibilidade` ausente após seed — veja a saída de seed-e2e-elegibilidade.ts");
|
||||
}
|
||||
}
|
||||
|
||||
function helper<T = unknown>(...args: string[]): T {
|
||||
const stdout = execFileSync("npx", ["tsx", "scripts/e2e-elegibilidade-helpers.ts", ...args], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
const last = stdout.trim().split("\n").filter(Boolean).pop();
|
||||
if (!last) throw new Error(`helper ${args[0]} não imprimiu JSON`);
|
||||
return JSON.parse(last) as T;
|
||||
}
|
||||
|
||||
async function login(page: Page, email: string): Promise<void> {
|
||||
await page.goto(`${APP_URL}/login`);
|
||||
await page.locator("#email").fill(email);
|
||||
await page.locator("#password").fill(creds.password);
|
||||
await page.getByRole("button", { name: /entrar/i }).click();
|
||||
await page.waitForURL(/\/app\//);
|
||||
}
|
||||
|
||||
/** Payload do Respondi com telefone + respondent_id únicos por execução. */
|
||||
function respondiPayload(phone: string, respondentId: string): unknown {
|
||||
const base = JSON.parse(fs.readFileSync(RESPONDI_FIXTURE, "utf8")) as {
|
||||
respondent: { respondent_id?: string; answers: Record<string, string> };
|
||||
};
|
||||
base.respondent.respondent_id = respondentId;
|
||||
base.respondent.answers[RESPONDI_PHONE_ALIAS] = phone;
|
||||
return base;
|
||||
}
|
||||
|
||||
async function esperarContatoPorTelefone(digits: string): Promise<{ id: string; ai_authorized_at: string | null; ai_authorized_reason: string | null }> {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const c = helper<{ id: string; ai_authorized_at: string | null; ai_authorized_reason: string | null } | null>(
|
||||
"find-contact-by-phone",
|
||||
digits,
|
||||
);
|
||||
if (c?.id) return c;
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
throw new Error(`contato do telefone ${digits} não apareceu em 10s`);
|
||||
}
|
||||
|
||||
async function esperarDispatch(contactId: string): Promise<void> {
|
||||
for (let i = 0; i < 24; i++) {
|
||||
const ev = helper<{ id: string } | null>("dispatch-event", contactId);
|
||||
if (ev?.id) return;
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
throw new Error(`ai_agent.dispatch_requested do contato ${contactId} não apareceu em 12s`);
|
||||
}
|
||||
|
||||
test.describe("J20.6 — a submissão do Respondi autoriza a IA", () => {
|
||||
test.describe.configure({ timeout: 240_000 });
|
||||
test.use({ actionTimeout: 15_000 });
|
||||
|
||||
test.beforeAll(() => {
|
||||
seedBase();
|
||||
});
|
||||
|
||||
test("Respondi autoriza o contato; o retorno do lead gera turno; número não-autorizado NÃO gera", async ({
|
||||
page,
|
||||
}) => {
|
||||
const session = "e2e-elegibilidade-session";
|
||||
const tail = String(Date.now()).slice(-8);
|
||||
|
||||
// AUTORIZADO — passa pelo Respondi.
|
||||
const respPhone = `55 11 9${tail}`;
|
||||
const respDigits = `9${tail}`;
|
||||
const respChatId = `55119${tail}@c.us`;
|
||||
const respondentId = `e2e-${Date.now()}`;
|
||||
|
||||
// CONTROLE — mesmo canal, sem Respondi.
|
||||
const ctrlTail = String(Date.now() + 1).slice(-8);
|
||||
const ctrlChatId = `55119${ctrlTail}@c.us`;
|
||||
const ctrlDigits = `9${ctrlTail}`;
|
||||
|
||||
let agentId = "";
|
||||
let respContactId = "";
|
||||
let ctrlContactId = "";
|
||||
|
||||
try {
|
||||
await login(page, creds.users.manager!.email);
|
||||
// Agente publicado no canal do gate — SETUP (o `POST /api/v1/ai/agents`
|
||||
// exige role `admin`/MFA e o agente não é o que está sob teste; sem ele o
|
||||
// drain pularia por "nenhum agente publicado para a sessão").
|
||||
agentId = helper<{ agentId: string }>("publish-agent").agentId;
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// (1) A submissão do Respondi entra pela URL da fonte de captação.
|
||||
// ---------------------------------------------------------------------
|
||||
const token = creds.elegibilidade!.webhook_source_token;
|
||||
const sub = await page.request.post(`${APP_URL}/api/v1/webhooks/in/${token}`, {
|
||||
data: respondiPayload(respPhone, respondentId),
|
||||
});
|
||||
expect(sub.status(), await sub.text()).toBe(200);
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// (2) O contato ficou ELEGÍVEL — carimbo com a origem.
|
||||
// ---------------------------------------------------------------------
|
||||
const contato = await esperarContatoPorTelefone(respDigits);
|
||||
respContactId = contato.id;
|
||||
await expect
|
||||
.poll(
|
||||
() => helper<{ ai_authorized_at: string | null }>("contact-authorization", respContactId).ai_authorized_at,
|
||||
{ timeout: 15_000, message: "a submissão do Respondi tem de carimbar ai_authorized_at" },
|
||||
)
|
||||
.not.toBeNull();
|
||||
expect(
|
||||
helper<{ ai_authorized_reason: string | null }>("contact-authorization", respContactId).ai_authorized_reason,
|
||||
).toMatch(/^respondi:/);
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// (3) O lead volta pelo WhatsApp → dispatch → drain ENFILEIRA o turno.
|
||||
// ---------------------------------------------------------------------
|
||||
const inboundRespondi = await fetch(`${APP_URL}/api/v1/webhooks/waha/${creds.elegibilidade!.waha_path_token}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
event: "message",
|
||||
session,
|
||||
payload: {
|
||||
id: `false_${respChatId}_J206A${Date.now()}`,
|
||||
from: respChatId,
|
||||
fromMe: false,
|
||||
body: "Oi, recebi o contato de vocês. Podem me explicar como funciona?",
|
||||
type: "chat",
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(inboundRespondi.status).toBe(200);
|
||||
await esperarDispatch(respContactId);
|
||||
|
||||
helper("drain-once");
|
||||
|
||||
await expect
|
||||
.poll(() => helper<{ id: string } | null>("job-inbound-turn", respContactId), {
|
||||
timeout: 20_000,
|
||||
message: "contato autorizado pelo Respondi: o drain tem de enfileirar o turno",
|
||||
})
|
||||
.not.toBeNull();
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// (4) CONTROLE — número que não passou pelo Respondi, no mesmo canal.
|
||||
// ---------------------------------------------------------------------
|
||||
const inboundCtrl = await fetch(`${APP_URL}/api/v1/webhooks/waha/${creds.elegibilidade!.waha_path_token}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
event: "message",
|
||||
session,
|
||||
payload: {
|
||||
id: `false_${ctrlChatId}_J206B${Date.now()}`,
|
||||
from: ctrlChatId,
|
||||
fromMe: false,
|
||||
body: "Bom dia, tudo bem?",
|
||||
type: "chat",
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(inboundCtrl.status).toBe(200);
|
||||
ctrlContactId = (await esperarContatoPorTelefone(ctrlDigits)).id;
|
||||
await esperarDispatch(ctrlContactId);
|
||||
|
||||
helper("drain-once");
|
||||
|
||||
// O evento foi CONSUMIDO (done) mas SEM job — a IA não assume.
|
||||
await expect
|
||||
.poll(() => helper<{ status: string } | null>("dispatch-event", ctrlContactId)?.status ?? null, {
|
||||
timeout: 20_000,
|
||||
})
|
||||
.toBe("done");
|
||||
expect(
|
||||
helper<{ id: string } | null>("job-inbound-turn", ctrlContactId),
|
||||
"contato NÃO autorizado no canal com gate: nenhum turno enfileirado",
|
||||
).toBeNull();
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// (5) Evidência: as duas conversas na inbox.
|
||||
// ---------------------------------------------------------------------
|
||||
await page.goto(`${APP_URL}/app/inbox`);
|
||||
await page.waitForLoadState("networkidle");
|
||||
fs.mkdirSync(EVIDENCIA, { recursive: true });
|
||||
await page.screenshot({ path: path.join(EVIDENCIA, "06-inbox-respondi-vs-controle.png"), fullPage: true });
|
||||
} finally {
|
||||
for (const id of [respContactId, ctrlContactId]) {
|
||||
if (id) {
|
||||
try {
|
||||
helper("cleanup-contact", id);
|
||||
} catch (e) {
|
||||
console.error("[cleanup] contato falhou:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (agentId) {
|
||||
try {
|
||||
helper("archive-agent", agentId);
|
||||
} catch (e) {
|
||||
console.error("[cleanup] archive-agent falhou:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { describe, it, expect, beforeAll } from "vitest";
|
||||
|
||||
import { GOV_ORG, GOV_SESSION, GOV_CONV_UNASSIGNED, GOV_CONTACT_1, seedGov, sql } from "./gov-helpers";
|
||||
|
||||
/**
|
||||
* R7 — o anti-backlog do drain (`lib/agent-engine/edge/crm/drain.ts`) elege "a
|
||||
* última inbound da conversa" para decidir se um evento foi SUPERADO. A consulta
|
||||
* era `order by sent_at desc nulls last, id desc` — e o `id` é uuid aleatório.
|
||||
* Dois inbound com o MESMO `sent_at` (relógio do provider repetido) faziam a
|
||||
* escolha da última cair no sorteio do uuid: metade das vezes elegia a ANTIGA e
|
||||
* o drain deixava passar um evento que devia ter sido pulado — a IA respondendo
|
||||
* mensagem velha.
|
||||
*
|
||||
* A correção usa `coalesce(sent_at, created_at) desc, created_at desc, id desc`
|
||||
* (padrão do repo — migration 0027). `created_at` é a ordem de INGESTÃO (uma
|
||||
* mensagem por webhook), então empate de `sent_at` desempata determinístico.
|
||||
*
|
||||
* Este invariante roda a MESMA cláusula contra Postgres real, com duas linhas de
|
||||
* `sent_at` idêntico, e prova que a de `created_at` mais novo vence — para
|
||||
* qualquer par de uuids.
|
||||
*/
|
||||
|
||||
const CV = GOV_CONV_UNASSIGNED;
|
||||
const CT = GOV_CONTACT_1;
|
||||
|
||||
// `id` fixos escolhidos para que ORDER BY `id desc` sozinho elegeria a ANTIGA
|
||||
// (a1... < f9..., então 'f9' desc viria primeiro — e é a mensagem antiga).
|
||||
const MSG_ANTIGA = "aaaaaaaa-0000-4000-8000-0000000000f9";
|
||||
const MSG_NOVA = "aaaaaaaa-0000-4000-8000-0000000000a1";
|
||||
|
||||
/** Cláusula EXATA do drain.ts (mantida em sincronia à mão — a cerca é o ponto). */
|
||||
const ORDER_BY = "order by coalesce(sent_at, created_at) desc, created_at desc, id desc";
|
||||
|
||||
function ultimaInbound(): string {
|
||||
return sql(`select id from public.messages
|
||||
where organization_id = '${GOV_ORG}' and conversation_id = '${CV}' and direction = 'inbound'
|
||||
${ORDER_BY}
|
||||
limit 1;`).trim();
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
seedGov();
|
||||
sql(`delete from public.messages where conversation_id = '${CV}';`);
|
||||
// Mesmo sent_at nos dois; created_at 10 min mais novo na "NOVA".
|
||||
sql(`insert into public.messages
|
||||
(id, organization_id, conversation_id, channel_session_id, contact_id,
|
||||
type, direction, status, sent_via, body, sent_at, created_at)
|
||||
values
|
||||
('${MSG_ANTIGA}', '${GOV_ORG}', '${CV}', '${GOV_SESSION}', '${CT}',
|
||||
'text', 'inbound', 'received', 'ai', 'primeira', timestamptz '2026-08-27 10:00:00Z', timestamptz '2026-08-27 10:00:00Z'),
|
||||
('${MSG_NOVA}', '${GOV_ORG}', '${CV}', '${GOV_SESSION}', '${CT}',
|
||||
'text', 'inbound', 'received', 'ai', 'segunda', timestamptz '2026-08-27 10:00:00Z', timestamptz '2026-08-27 10:10:00Z');`);
|
||||
});
|
||||
|
||||
describe("R7 · anti-backlog: a última inbound é a mais RECENTE, não a de maior uuid", () => {
|
||||
it("sent_at empatado → vence quem tem created_at mais novo", () => {
|
||||
expect(ultimaInbound()).toBe(MSG_NOVA);
|
||||
});
|
||||
|
||||
it("a cláusula NÃO é a antiga (`nulls last` + `id desc` sozinho)", () => {
|
||||
expect(ORDER_BY).not.toContain("nulls last");
|
||||
// Sanidade: ORDER BY só `id desc` elegeria a ANTIGA — prova que o teste morde.
|
||||
const soPorId = sql(`select id from public.messages
|
||||
where organization_id = '${GOV_ORG}' and conversation_id = '${CV}' and direction = 'inbound'
|
||||
order by id desc limit 1;`).trim();
|
||||
expect(soPorId).toBe(MSG_ANTIGA);
|
||||
});
|
||||
|
||||
it("drain.ts usa exatamente esta cláusula (guarda contra drift do inline SQL)", () => {
|
||||
const fonte = readFileSync(
|
||||
resolve(__dirname, "../../lib/agent-engine/edge/crm/drain.ts"),
|
||||
"utf-8",
|
||||
);
|
||||
expect(fonte).toContain(ORDER_BY);
|
||||
expect(fonte).not.toContain("order by sent_at desc nulls last");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,249 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import pg from "pg";
|
||||
|
||||
import {
|
||||
checkAgentePublicado,
|
||||
checkCampanhas,
|
||||
checkCoberturaDosCaminhos,
|
||||
checkDenyByDefault,
|
||||
checkImpactoConversas,
|
||||
checkPlanoDeEscrita,
|
||||
checkQueryElegibilidade,
|
||||
checkRespondiAutoriza,
|
||||
checkSchema0203,
|
||||
type CtxAtivacao,
|
||||
} from "../../scripts/lib/gate-ativacao";
|
||||
|
||||
/**
|
||||
* O preflight do `scripts/ativar-gate-elegibilidade-ia.ts` contra Postgres REAL
|
||||
* (baseline.sql aplicado pelo harness). Prova que:
|
||||
* - a query base da elegibilidade roda no schema do produto;
|
||||
* - "agente publicado" é detectado;
|
||||
* - a contagem de conversas que perdem a IA está certa;
|
||||
* - contato antigo / sem origem NÃO é autorizado em modo allowlist;
|
||||
* - campanha genérica é RECUSADA (FAIL), específica passa;
|
||||
* - o plano de escrita toca SÓ channel_sessions.
|
||||
*/
|
||||
|
||||
const container = process.env.TEST_DB_CONTAINER;
|
||||
if (!container) throw new Error("TEST_DB_CONTAINER não setado — rode via scripts/test-db.sh");
|
||||
const PORT = Number(process.env.TEST_DB_PORT ?? 54329);
|
||||
const pool = new pg.Pool({ connectionString: `postgresql://postgres:postgres@127.0.0.1:${PORT}/postgres`, max: 2 });
|
||||
|
||||
const P = "d1a7e000-0000-4000-8000-0000000000";
|
||||
const ORG = `${P}01`;
|
||||
const CANAL = `${P}02`;
|
||||
const OUTRO_CANAL = `${P}03`;
|
||||
const AGENT = `${P}04`;
|
||||
const VERSION = `${P}05`;
|
||||
const PIPE = `${P}06`;
|
||||
const STAGE = `${P}07`;
|
||||
const WSRC = `${P}08`;
|
||||
// contatos
|
||||
const CT_AUTORIZADO = `${P}10`;
|
||||
const CT_NAO_AUTORIZADO = `${P}11`;
|
||||
const CT_ANTIGO = `${P}12`;
|
||||
const CT_HANDOFF = `${P}13`;
|
||||
// conversas
|
||||
const CV_AUTORIZADO = `${P}20`;
|
||||
const CV_NAO_AUTORIZADO = `${P}21`;
|
||||
const CV_ANTIGO = `${P}22`;
|
||||
const CV_HANDOFF = `${P}23`;
|
||||
|
||||
async function q(texto: string, params: unknown[] = []): Promise<Array<Record<string, unknown>>> {
|
||||
return (await pool.query(texto, params)).rows;
|
||||
}
|
||||
|
||||
function ctx(over: Partial<CtxAtivacao> = {}): CtxAtivacao {
|
||||
return {
|
||||
pool: { query: (t, p) => pool.query(t, p as unknown[]) },
|
||||
organizationId: ORG,
|
||||
channelSessionId: CANAL,
|
||||
channelMetadata: {},
|
||||
raiz: process.cwd(),
|
||||
ttlMs: 21 * 86_400_000,
|
||||
alvoModo: "allowlist",
|
||||
rollback: false,
|
||||
opcoes: { permitirSemAgente: false, campanhasPerigosasOk: false, tamAmostra: 25 },
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await q(
|
||||
`insert into organizations (id, slug, legal_name, display_name, settings)
|
||||
values ($1,'gate-ativ','Gate Ativ','Gate Ativ','{}'::jsonb) on conflict do nothing`,
|
||||
[ORG],
|
||||
);
|
||||
for (const [id, nome] of [
|
||||
[CANAL, "gate-ativ-canal"],
|
||||
[OUTRO_CANAL, "gate-ativ-outro"],
|
||||
] as const) {
|
||||
await q(
|
||||
`do $$ begin
|
||||
insert into channel_sessions (id, organization_id, waha_session_name, webhook_secret_encrypted)
|
||||
values ('${id}', '${ORG}', '${nome}', '\\x00'::bytea);
|
||||
exception when unique_violation then null; end $$`,
|
||||
);
|
||||
}
|
||||
await q(
|
||||
`insert into ai_agents (id, organization_id, name, system_prompt) values ($1,$2,'Agente Gate','sp') on conflict (id) do nothing`,
|
||||
[AGENT, ORG],
|
||||
);
|
||||
await q(
|
||||
`insert into ai_agent_versions (id, organization_id, agent_id, version_number, system_prompt, provider, model, channel_session_id, status, published_at)
|
||||
values ($1,$2,$3,1,'sp','anthropic','anthropic/claude-sonnet-4-6',$4,'published',now())
|
||||
on conflict (id) do nothing`,
|
||||
[VERSION, ORG, AGENT, CANAL],
|
||||
);
|
||||
await q(`update ai_agents set published_version_id = $2 where id = $1`, [AGENT, VERSION]);
|
||||
await q(`insert into crm_pipelines (id, organization_id, name, slug) values ($1,$2,'P','gate-p') on conflict do nothing`, [PIPE, ORG]);
|
||||
await q(
|
||||
`insert into crm_stages (id, organization_id, pipeline_id, name, slug, position) values ($1,$2,$3,'S','gate-s',1000) on conflict do nothing`,
|
||||
[STAGE, ORG, PIPE],
|
||||
);
|
||||
|
||||
// contatos
|
||||
const agora = Date.now();
|
||||
await q(
|
||||
`insert into contacts (id, organization_id, display_name, ai_authorized_at, ai_authorized_reason, created_at)
|
||||
values
|
||||
($1,$5,'Autorizado', now(), 'respondi:form-1:sub-1', now()),
|
||||
($2,$5,'Nao autorizado', null, null, now()),
|
||||
($3,$5,'Antigo', null, null, to_timestamp(${Math.floor(agora / 1000) - 400 * 86400})),
|
||||
($4,$5,'Handoff', null, null, now())
|
||||
on conflict (id) do update set ai_authorized_at = excluded.ai_authorized_at, ai_authorized_reason = excluded.ai_authorized_reason`,
|
||||
[CT_AUTORIZADO, CT_NAO_AUTORIZADO, CT_ANTIGO, CT_HANDOFF, ORG],
|
||||
);
|
||||
await q(`update contacts set force_human = true where id = $1`, [CT_HANDOFF]);
|
||||
|
||||
// conversas — todas no CANAL
|
||||
for (const [cv, ct] of [
|
||||
[CV_AUTORIZADO, CT_AUTORIZADO],
|
||||
[CV_NAO_AUTORIZADO, CT_NAO_AUTORIZADO],
|
||||
[CV_ANTIGO, CT_ANTIGO],
|
||||
[CV_HANDOFF, CT_HANDOFF],
|
||||
] as const) {
|
||||
await q(
|
||||
`insert into conversations (id, organization_id, contact_id, channel_session_id, status, last_inbound_at, last_message_at)
|
||||
values ($1,$2,$3,$4,'open', now(), now()) on conflict do nothing`,
|
||||
[cv, ORG, ct, CANAL],
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await q(`delete from conversations where organization_id = $1`, [ORG]);
|
||||
await q(`update ai_agents set published_version_id = null where organization_id = $1`, [ORG]);
|
||||
await q(`delete from ai_agent_versions where organization_id = $1`, [ORG]);
|
||||
await q(`delete from ai_agents where organization_id = $1`, [ORG]);
|
||||
await q(`delete from webhook_sources where organization_id = $1`, [ORG]);
|
||||
await q(`delete from crm_stages where organization_id = $1`, [ORG]);
|
||||
await q(`delete from crm_pipelines where organization_id = $1`, [ORG]);
|
||||
await q(`delete from contacts where organization_id = $1`, [ORG]);
|
||||
await q(`delete from channel_sessions where organization_id = $1`, [ORG]);
|
||||
await q(`delete from organizations where id = $1`, [ORG]);
|
||||
await pool.end();
|
||||
});
|
||||
|
||||
describe("preflight do ativar-gate contra Postgres real", () => {
|
||||
it("checkSchema0203 · PASS (0203 está no baseline)", async () => {
|
||||
const r = await checkSchema0203(ctx());
|
||||
expect(r.status).toBe("PASS");
|
||||
});
|
||||
|
||||
it("checkQueryElegibilidade · PASS e roda a regra numa conversa real", async () => {
|
||||
const r = await checkQueryElegibilidade(ctx());
|
||||
expect(r.status).toBe("PASS");
|
||||
expect(r.linhas?.join("\n")).toMatch(/query base .* resolveu/);
|
||||
expect(r.linhas?.join("\n")).toMatch(/conversa real .*com allowlist/);
|
||||
});
|
||||
|
||||
it("checkAgentePublicado · PASS quando há versão publicada no canal", async () => {
|
||||
expect((await checkAgentePublicado(ctx())).status).toBe("PASS");
|
||||
});
|
||||
|
||||
it("checkAgentePublicado · FAIL num canal sem agente, WARN com --permitir-sem-agente", async () => {
|
||||
expect((await checkAgentePublicado(ctx({ channelSessionId: OUTRO_CANAL }))).status).toBe("FAIL");
|
||||
const warn = await checkAgentePublicado(
|
||||
ctx({ channelSessionId: OUTRO_CANAL, opcoes: { permitirSemAgente: true, campanhasPerigosasOk: false, tamAmostra: 25 } }),
|
||||
);
|
||||
expect(warn.status).toBe("WARN");
|
||||
});
|
||||
|
||||
it("checkImpactoConversas · conta certo quem perde / mantém / já bloqueada", async () => {
|
||||
const r = await checkImpactoConversas(ctx());
|
||||
// CV_NAO_AUTORIZADO e CV_ANTIGO perdem (2); CV_AUTORIZADO mantém (1);
|
||||
// CV_HANDOFF já bloqueada por force_human (1).
|
||||
expect(r.detalhe).toMatch(/^2 conversa\(s\) deixam de ser atendidas/);
|
||||
expect(r.detalhe).toMatch(/1 seguem elegíveis/);
|
||||
expect(r.detalhe).toMatch(/1 já não recebiam IA hoje/);
|
||||
});
|
||||
|
||||
it("checkDenyByDefault · contato antigo e simulações NÃO autorizam; PASS", async () => {
|
||||
const r = await checkDenyByDefault(ctx());
|
||||
expect(r.status).toBe("PASS");
|
||||
expect(r.linhas?.join("\n")).toMatch(/não autorizado \(sem_autorizacao\)/);
|
||||
expect(r.linhas?.join("\n")).not.toMatch(/AUTORIZADO\?!/);
|
||||
expect(r.linhas?.join("\n")).toMatch(/mensagem nova, contato nunca autorizado.*não responde/);
|
||||
expect(r.linhas?.join("\n")).toMatch(/mais velha que o TTL.*não responde \(autorizacao_expirada\)/);
|
||||
});
|
||||
|
||||
it("checkDenyByDefault · autorização com reason fora do vocabulário → FAIL", async () => {
|
||||
await q(`update contacts set ai_authorized_reason = 'importado_planilha' where id = $1`, [CT_AUTORIZADO]);
|
||||
const r = await checkDenyByDefault(ctx());
|
||||
await q(`update contacts set ai_authorized_reason = 'respondi:form-1:sub-1' where id = $1`, [CT_AUTORIZADO]);
|
||||
expect(r.status).toBe("FAIL");
|
||||
expect(r.detalhe).toMatch(/fora do produto/);
|
||||
});
|
||||
|
||||
it("checkCampanhas · genérica → FAIL; específica → PASS; presa a outro canal → PASS", async () => {
|
||||
await q(
|
||||
`update organizations set settings = jsonb_set(settings,'{campanhas_whatsapp}', $2::jsonb) where id = $1`,
|
||||
[ORG, JSON.stringify([{ id: "generica", match: { tipo: "contains", valor: "bom dia" } }])],
|
||||
);
|
||||
expect((await checkCampanhas(ctx())).status).toBe("FAIL");
|
||||
|
||||
await q(
|
||||
`update organizations set settings = jsonb_set(settings,'{campanhas_whatsapp}', $2::jsonb) where id = $1`,
|
||||
[ORG, JSON.stringify([{ id: "generica-outro", channel_session_id: OUTRO_CANAL, match: { tipo: "contains", valor: "bom dia" } }])],
|
||||
);
|
||||
expect((await checkCampanhas(ctx())).status).toBe("PASS");
|
||||
|
||||
await q(
|
||||
`update organizations set settings = jsonb_set(settings,'{campanhas_whatsapp}', $2::jsonb) where id = $1`,
|
||||
[ORG, JSON.stringify([{ id: "boa", match: { tipo: "contains", valor: "quero saber sobre o plano imobiliario premium" } }])],
|
||||
);
|
||||
expect((await checkCampanhas(ctx())).status).toBe("PASS");
|
||||
|
||||
await q(`update organizations set settings = settings - 'campanhas_whatsapp' where id = $1`, [ORG]);
|
||||
});
|
||||
|
||||
it("checkRespondiAutoriza · WARN sem fonte, PASS com fonte ativa", async () => {
|
||||
expect((await checkRespondiAutoriza(ctx())).status).toBe("WARN");
|
||||
await q(
|
||||
`insert into webhook_sources (id, organization_id, name, path_token, default_pipeline_id, default_stage_id)
|
||||
values ($1,$2,'Respondi','tok-gate-ativ',$3,$4) on conflict do nothing`,
|
||||
[WSRC, ORG, PIPE, STAGE],
|
||||
);
|
||||
const r = await checkRespondiAutoriza(ctx());
|
||||
await q(`delete from webhook_sources where id = $1`, [WSRC]);
|
||||
expect(r.status).toBe("PASS");
|
||||
expect(r.linhas?.join("\n")).toMatch(/service_role pode UPDATE contacts.ai_authorized_at/);
|
||||
});
|
||||
|
||||
it("checkCoberturaDosCaminhos · PASS (checkout, todos os caminhos com o marcador)", async () => {
|
||||
const r = await checkCoberturaDosCaminhos(ctx());
|
||||
expect(["PASS", "INFO"]).toContain(r.status);
|
||||
expect(r.status).not.toBe("FAIL");
|
||||
});
|
||||
|
||||
it("checkPlanoDeEscrita · descreve a única escrita, NÃO toca contacts", () => {
|
||||
const r = checkPlanoDeEscrita(ctx());
|
||||
const txt = r.linhas?.join("\n") ?? "";
|
||||
expect(txt).toMatch(/update channel_sessions/);
|
||||
expect(txt).toMatch(/ZERO autorização em massa/);
|
||||
expect(txt).not.toMatch(/update contacts/);
|
||||
expect(txt).not.toMatch(/update conversations/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* R1 — o worker LEGADO (`workers/ai-response-worker.ts`, o caminho pré-engine
|
||||
* para orgs sem versão de agente publicada) TAMBÉM respeita o gate de
|
||||
* elegibilidade. Sem isto, ligar `channel_sessions.metadata.ai_gate='allowlist'`
|
||||
* num canal não fazia nada nessas orgs: o log não reclamava e a IA seguia
|
||||
* respondendo todo mundo por aqui (a "falha-em-verde" da doutrina).
|
||||
*
|
||||
* Prova, contra o worker REAL (admin client + gateway mockados):
|
||||
* - gate 'allowlist' + contato não autorizado → skip 'nao_elegivel_para_ia',
|
||||
* ANTES de qualquer leitura de mensagem/agente;
|
||||
* - gate 'open' (default) → o guard não veta;
|
||||
* - erro na leitura da elegibilidade → skip 'nao_elegivel_para_ia' (fail-closed).
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// `lib/env.ts` valida na importação e o worker o alcança via
|
||||
// `aes_gcm`/`gateway`. Mesma isca dos irmãos (`ai-response-worker-sent-via`):
|
||||
// só a chave da Anthropic, como o `install.sh` produz.
|
||||
const envMock: Record<string, string> = {
|
||||
ANTHROPIC_API_KEY: "sk-ant-teste",
|
||||
AI_GATEWAY_API_KEY: "",
|
||||
AI_GATEWAY_BASE_URL: "",
|
||||
OPENROUTER_API_KEY: "",
|
||||
OPENROUTER_BASE_URL: "",
|
||||
OPENAI_API_KEY: "",
|
||||
};
|
||||
vi.mock("@/lib/env", () => ({
|
||||
get env() {
|
||||
return envMock;
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/supabase/admin", () => ({ createAdminClient: vi.fn() }));
|
||||
vi.mock("@/lib/logger", () => ({
|
||||
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
}));
|
||||
vi.mock("@/lib/ai/gateway", () => ({
|
||||
DEFAULT_BOT_MODEL: "anthropic/claude-sonnet-4-6",
|
||||
gatewayConfig: {},
|
||||
gatewayHeaders: () => ({}),
|
||||
isAiGatewayConfigured: () => true,
|
||||
isEmbeddingProviderConfigured: () => false,
|
||||
}));
|
||||
|
||||
import { processMessageReceived } from "@/workers/ai-response-worker";
|
||||
import { createAdminClient } from "@/lib/supabase/admin";
|
||||
import type { EventRow } from "@/lib/event-log/dispatcher";
|
||||
|
||||
const ORG_ID = "22222222-2222-4222-8222-222222222222";
|
||||
const CONV_ID = "44444444-4444-4444-8444-444444444444";
|
||||
const MSG_ID = "55555555-5555-4555-8555-555555555555";
|
||||
const CONTACT_ID = "66666666-6666-4666-8666-666666666666";
|
||||
|
||||
interface ConvOpts {
|
||||
aiGate?: string | null;
|
||||
aiAuthorizedAt?: string | null;
|
||||
convError?: string;
|
||||
}
|
||||
|
||||
function makeAdminStub(opts: ConvOpts, queried: string[]) {
|
||||
const convRow = {
|
||||
id: CONV_ID,
|
||||
organization_id: ORG_ID,
|
||||
contact_id: CONTACT_ID,
|
||||
channel_session_id: "77777777-7777-4777-8777-777777777777",
|
||||
last_inbound_at: new Date().toISOString(),
|
||||
bot_silenced_until: null,
|
||||
last_handoff_at: null,
|
||||
assignee_kind: "ai",
|
||||
contacts: {
|
||||
id: CONTACT_ID,
|
||||
display_name: null,
|
||||
locale: "pt-BR",
|
||||
is_blocked: false,
|
||||
force_human: false,
|
||||
ai_authorized_at: opts.aiAuthorizedAt ?? null,
|
||||
},
|
||||
channel_sessions: { metadata: opts.aiGate != null ? { ai_gate: opts.aiGate } : {} },
|
||||
};
|
||||
|
||||
const from = (table: string) => {
|
||||
queried.push(table);
|
||||
const result = table === "conversations" ? convRow : table === "messages" ? { id: MSG_ID, body: "oi", direction: "inbound", organization_id: ORG_ID } : null;
|
||||
let selectCols = "";
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const chain: any = {
|
||||
select: (cols?: string) => {
|
||||
selectCols = cols ?? "";
|
||||
return chain;
|
||||
},
|
||||
eq: () => chain,
|
||||
is: () => chain,
|
||||
in: () => chain,
|
||||
not: () => chain,
|
||||
order: () => chain,
|
||||
limit: () => chain,
|
||||
maybeSingle: () => {
|
||||
// O erro só na consulta de ELEGIBILIDADE (embed de channel_sessions),
|
||||
// não na leitura própria do buildContext.
|
||||
const ehConsultaElegibilidade =
|
||||
table === "conversations" && selectCols.includes("channel_sessions:channel_session_id");
|
||||
if (ehConsultaElegibilidade && opts.convError) {
|
||||
return Promise.resolve({ data: null, error: { message: opts.convError } });
|
||||
}
|
||||
return Promise.resolve({ data: result, error: null });
|
||||
},
|
||||
then: (r: (v: unknown) => unknown) =>
|
||||
Promise.resolve({ data: result ? [result] : [], error: null }).then(r),
|
||||
};
|
||||
return chain;
|
||||
};
|
||||
return { from } as never;
|
||||
}
|
||||
|
||||
const eventRow = {
|
||||
organization_id: ORG_ID,
|
||||
entity_id: MSG_ID,
|
||||
payload: { message_id: MSG_ID, conversation_id: CONV_ID },
|
||||
} as unknown as EventRow;
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe("ai-response-worker (legado) · gate de elegibilidade", () => {
|
||||
it("gate 'allowlist' + contato NÃO autorizado → skip 'nao_elegivel_para_ia'", async () => {
|
||||
const queried: string[] = [];
|
||||
vi.mocked(createAdminClient).mockReturnValue(
|
||||
makeAdminStub({ aiGate: "allowlist", aiAuthorizedAt: null }, queried),
|
||||
);
|
||||
const result = await processMessageReceived(eventRow);
|
||||
expect(result).toMatchObject({ status: "skipped", reason: "nao_elegivel_para_ia" });
|
||||
expect(queried).not.toContain("messages");
|
||||
expect(queried).not.toContain("ai_agents");
|
||||
});
|
||||
|
||||
it("gate 'allowlist' + contato autorizado → o guard não veta (avança no pipeline)", async () => {
|
||||
const queried: string[] = [];
|
||||
vi.mocked(createAdminClient).mockReturnValue(
|
||||
makeAdminStub(
|
||||
{ aiGate: "allowlist", aiAuthorizedAt: new Date().toISOString() },
|
||||
queried,
|
||||
),
|
||||
);
|
||||
const result = await processMessageReceived(eventRow);
|
||||
expect(result.reason).not.toBe("nao_elegivel_para_ia");
|
||||
expect(queried).toContain("messages");
|
||||
});
|
||||
|
||||
it("gate 'open' (default) → o guard não veta", async () => {
|
||||
const queried: string[] = [];
|
||||
vi.mocked(createAdminClient).mockReturnValue(
|
||||
makeAdminStub({ aiGate: null, aiAuthorizedAt: null }, queried),
|
||||
);
|
||||
const result = await processMessageReceived(eventRow);
|
||||
expect(result.reason).not.toBe("nao_elegivel_para_ia");
|
||||
expect(queried).toContain("messages");
|
||||
});
|
||||
|
||||
it("erro ao ler elegibilidade → skip 'nao_elegivel_para_ia' (fail-closed)", async () => {
|
||||
const queried: string[] = [];
|
||||
vi.mocked(createAdminClient).mockReturnValue(
|
||||
makeAdminStub(
|
||||
{ aiGate: "allowlist", convError: "column contacts.ai_authorized_at does not exist" },
|
||||
queried,
|
||||
),
|
||||
);
|
||||
const result = await processMessageReceived(eventRow);
|
||||
expect(result.status).toBe("skipped");
|
||||
expect(result.reason).toBe("nao_elegivel_para_ia");
|
||||
});
|
||||
});
|
||||
@@ -192,7 +192,16 @@ describe("devolver o atendimento ao agente", () => {
|
||||
expect(
|
||||
noContato,
|
||||
"sem esta escrita o agente continua morto nos três guards (worker nativo, harness e before-send)",
|
||||
).toEqual([{ tabela: "contacts", valores: { force_human: false } }]);
|
||||
).toContainEqual({ tabela: "contacts", valores: { force_human: false } });
|
||||
// Retomada manual = re-autoriza o contato (gate opt-in 'allowlist', 0203):
|
||||
// sem isto, "devolver ao automático" apagaria as travas de handoff e a IA
|
||||
// seguiria muda porque ai_authorized_at continuaria nulo/expirado.
|
||||
expect(noContato).toContainEqual(
|
||||
expect.objectContaining({
|
||||
tabela: "contacts",
|
||||
valores: expect.objectContaining({ ai_authorized_reason: "retomada_manual" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("devolve o comando da conversa: silêncio some, marca de passagem some, dono vira a IA", async () => {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* R1 — `triggerHandoff` (o caminho de handoff do CRM: worker de sentimento,
|
||||
* tool MCP, worker legado) só passa bot→humano uma conversa que a IA PODERIA
|
||||
* estar atendendo agora. Numa conversa que o gate `allowlist` barra — cliente
|
||||
* antigo irritado dispara `low_sentiment` —, disparar mandaria "um humano vai te
|
||||
* atender" e mexeria no estado de uma conversa que nunca foi da IA.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const CONV = "44444444-4444-4444-8444-444444444444";
|
||||
const ORG = "22222222-2222-4222-8222-222222222222";
|
||||
const CONTACT = "66666666-6666-4666-8666-666666666666";
|
||||
|
||||
const avisarLeadDoCrm = vi.fn(async (..._a: unknown[]) => ({ avisado: true }));
|
||||
const decidir = vi.fn();
|
||||
|
||||
vi.mock("@/lib/ai/handoff/aviso-ao-lead", () => ({ avisarLeadDoCrm: (...a: unknown[]) => avisarLeadDoCrm(...a) }));
|
||||
vi.mock("@/lib/ai/elegibilidade/consulta-supabase", () => ({
|
||||
decidirElegibilidadeDaConversaViaSupabase: (...a: unknown[]) => decidir(...a),
|
||||
}));
|
||||
vi.mock("@/lib/logger", () => ({ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() } }));
|
||||
|
||||
const updates: Array<Record<string, unknown>> = [];
|
||||
vi.mock("@/lib/supabase/admin", () => ({
|
||||
createAdminClient: () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const chain: any = {
|
||||
select: () => chain,
|
||||
update: (p: Record<string, unknown>) => {
|
||||
updates.push(p);
|
||||
return chain;
|
||||
},
|
||||
insert: () => chain,
|
||||
eq: () => chain,
|
||||
maybeSingle: () =>
|
||||
Promise.resolve({
|
||||
data: { id: CONV, organization_id: ORG, contact_id: CONTACT, last_handoff_at: null, last_handoff_reason: null },
|
||||
error: null,
|
||||
}),
|
||||
then: (r: (v: unknown) => unknown) => Promise.resolve({ error: null }).then(r),
|
||||
};
|
||||
return {
|
||||
from: () => chain,
|
||||
rpc: () => Promise.resolve({ error: null }),
|
||||
channel: () => ({ send: () => Promise.resolve(), subscribe: () => ({}) }),
|
||||
};
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/audit", () => ({ audit: vi.fn(), isServiceRoleConfigured: () => false }));
|
||||
|
||||
import { triggerHandoff } from "@/lib/ai/handoff/orchestrator";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
updates.length = 0;
|
||||
});
|
||||
|
||||
describe("triggerHandoff · gate de elegibilidade", () => {
|
||||
it("gate 'allowlist' + não autorizado (bloqueioPorAllowlist) → NÃO dispara, NÃO avisa, NÃO mexe na conversa", async () => {
|
||||
decidir.mockResolvedValue({ permite: false, motivo: "sem_autorizacao", bloqueioPorAllowlist: true });
|
||||
const r = await triggerHandoff({ conversationId: CONV, organizationId: ORG, reason: "low_sentiment" });
|
||||
expect(r.triggered).toBe(false);
|
||||
expect(r.reason).toContain("nao_elegivel");
|
||||
expect(avisarLeadDoCrm).not.toHaveBeenCalled();
|
||||
expect(updates).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("já duravelmente silenciada (conversa_silenciada) → NÃO re-dispara nem re-avisa", async () => {
|
||||
decidir.mockResolvedValue({ permite: false, motivo: "conversa_silenciada", bloqueioPorAllowlist: false });
|
||||
const r = await triggerHandoff({ conversationId: CONV, organizationId: ORG, reason: "low_confidence" });
|
||||
expect(r.triggered).toBe(false);
|
||||
expect(avisarLeadDoCrm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("erro ao ler elegibilidade → NÃO dispara (fail-closed, o evento re-tenta)", async () => {
|
||||
decidir.mockRejectedValue(new Error("db down"));
|
||||
const r = await triggerHandoff({ conversationId: CONV, organizationId: ORG, reason: "low_sentiment" });
|
||||
expect(r.triggered).toBe(false);
|
||||
expect(r.reason).toBe("elegibilidade_indeterminada");
|
||||
expect(avisarLeadDoCrm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("conversa elegível (permite) → segue: avisa o lead", async () => {
|
||||
decidir.mockResolvedValue({ permite: true, motivo: "autorizado", bloqueioPorAllowlist: false });
|
||||
const r = await triggerHandoff({ conversationId: CONV, organizationId: ORG, reason: "requested_human" });
|
||||
expect(avisarLeadDoCrm).toHaveBeenCalledOnce();
|
||||
expect(r.triggered).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* R8 — quando uma PESSOA responde o cliente pelo celular (mensagem `fromMe` que
|
||||
* NÃO é eco de um envio do CRM), a IA é pausada NESSA conversa. Silêncio
|
||||
* durável (`bot_silenced_until='infinity'` + rastro de handoff), sem tocar em
|
||||
* `contacts.ai_authorized_at` — a origem do lead é estado separado.
|
||||
*
|
||||
* Prova pelo `dispatchWahaEvent` real (admin client mockado).
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// `lib/waha/ingest.ts` alcança `lib/env.ts` (que valida na importação) via
|
||||
// `pos-entrada`/`ai-response-worker`. Mesma isca dos irmãos.
|
||||
const envMock: Record<string, string> = {
|
||||
ANTHROPIC_API_KEY: "sk-ant-teste",
|
||||
AI_GATEWAY_API_KEY: "",
|
||||
AI_GATEWAY_BASE_URL: "",
|
||||
OPENROUTER_API_KEY: "",
|
||||
OPENROUTER_BASE_URL: "",
|
||||
OPENAI_API_KEY: "",
|
||||
};
|
||||
vi.mock("@/lib/env", () => ({
|
||||
get env() {
|
||||
return envMock;
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/audit", () => ({ audit: vi.fn(async () => {}), isServiceRoleConfigured: () => false }));
|
||||
vi.mock("@/lib/logger", () => ({ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() } }));
|
||||
vi.mock("@/lib/channels/health", () => ({ sincronizarSaudeDaConexao: vi.fn(async () => {}) }));
|
||||
|
||||
import { dispatchWahaEvent } from "@/lib/waha/ingest";
|
||||
|
||||
const ORG = "org-1";
|
||||
const SESSION = { id: "sess-1", organization_id: ORG, is_warmup_complete: true, warmup_started_at: null };
|
||||
|
||||
interface Captura {
|
||||
conversationUpdates: Array<Record<string, unknown>>;
|
||||
rpcs: string[];
|
||||
}
|
||||
|
||||
function makeAdmin(cap: Captura, jaRegistrada: boolean) {
|
||||
const table = (name: string) => {
|
||||
let selectCols = "";
|
||||
let mode: "select" | "insert" | "update" = "select";
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const chain: any = {
|
||||
select: (c?: string) => {
|
||||
selectCols = c ?? "";
|
||||
return chain;
|
||||
},
|
||||
insert: () => {
|
||||
mode = "insert";
|
||||
return chain;
|
||||
},
|
||||
update: (p: Record<string, unknown>) => {
|
||||
mode = "update";
|
||||
if (name === "conversations") cap.conversationUpdates.push(p);
|
||||
return chain;
|
||||
},
|
||||
eq: () => chain,
|
||||
in: () => chain,
|
||||
limit: () => chain,
|
||||
maybeSingle: () => {
|
||||
if (name === "messages" && mode === "select") {
|
||||
// dedup por external_id: null = mensagem genuína do celular
|
||||
return Promise.resolve({ data: jaRegistrada ? { id: "eco" } : null, error: null });
|
||||
}
|
||||
if (name === "messages" && mode === "insert") {
|
||||
return Promise.resolve({ data: { id: "msg-nova" }, error: null });
|
||||
}
|
||||
if (name === "conversations" && selectCols.includes("bot_silenced_until")) {
|
||||
return Promise.resolve({ data: { bot_silenced_until: null }, error: null });
|
||||
}
|
||||
return Promise.resolve({ data: null, error: null });
|
||||
},
|
||||
then: (r: (v: unknown) => unknown) => Promise.resolve({ data: null, error: null }).then(r),
|
||||
};
|
||||
return chain;
|
||||
};
|
||||
return {
|
||||
from: (n: string) => table(n),
|
||||
rpc: (fn: string) => {
|
||||
cap.rpcs.push(fn);
|
||||
if (fn === "fn_upsert_wa_contact") return Promise.resolve({ data: "contact-1", error: null });
|
||||
if (fn === "fn_upsert_wa_conversation") return Promise.resolve({ data: "conv-1", error: null });
|
||||
return Promise.resolve({ data: null, error: null });
|
||||
},
|
||||
} as never;
|
||||
}
|
||||
|
||||
const envelopeFromMe = {
|
||||
event: "message.any",
|
||||
payload: {
|
||||
id: "true_5511999999999@c.us_ABCD",
|
||||
fromMe: true,
|
||||
to: "5511999999999@c.us",
|
||||
body: "Oi! Já te respondo com os detalhes.",
|
||||
type: "text",
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe("R8 · resposta manual pelo celular pausa a IA", () => {
|
||||
it("mensagem fromMe GENUÍNA → grava bot_silenced_until='infinity' + rastro, sem tocar autorização", async () => {
|
||||
const cap: Captura = { conversationUpdates: [], rpcs: [] };
|
||||
await dispatchWahaEvent(makeAdmin(cap, false), SESSION, envelopeFromMe, "req-1");
|
||||
|
||||
const pausa = cap.conversationUpdates.find((u) => u.bot_silenced_until === "infinity");
|
||||
expect(pausa).toBeDefined();
|
||||
expect(pausa).toHaveProperty("last_handoff_at");
|
||||
expect(String(pausa!.last_handoff_reason)).toMatch(/manual/i);
|
||||
// NÃO toca a elegibilidade do lead.
|
||||
for (const u of cap.conversationUpdates) {
|
||||
expect(u).not.toHaveProperty("ai_authorized_at");
|
||||
}
|
||||
});
|
||||
|
||||
it("eco do próprio envio do CRM (já registrado) → NÃO pausa nada", async () => {
|
||||
const cap: Captura = { conversationUpdates: [], rpcs: [] };
|
||||
await dispatchWahaEvent(makeAdmin(cap, true), SESSION, envelopeFromMe, "req-2");
|
||||
expect(cap.conversationUpdates.find((u) => u.bot_silenced_until === "infinity")).toBeUndefined();
|
||||
});
|
||||
|
||||
/**
|
||||
* O canal Zernio tem o MESMO caminho de saída-por-fora-do-CRM (`insertMessage`
|
||||
* com `direction='outbound'`). A pausa é o mesmo helper — a guarda cobra que a
|
||||
* chamada esteja lá também, já que o fake do teste de ingestão do Zernio não
|
||||
* exercita a leitura de `bot_silenced_until`.
|
||||
*/
|
||||
it("zernio: o caminho de saída manual também chama pausarIaPorAtendimentoManual", () => {
|
||||
const fonte = readFileSync(
|
||||
resolve(__dirname, "../../lib/channels/zernio/ingest.ts"),
|
||||
"utf-8",
|
||||
);
|
||||
expect(fonte).toContain("pausarIaPorAtendimentoManual");
|
||||
expect(fonte).toMatch(/direction === "outbound"[\s\S]{0,200}pausarIaPorAtendimentoManual/);
|
||||
});
|
||||
});
|
||||
@@ -283,6 +283,7 @@ export async function startWorker(
|
||||
idleIntervalMs: env.CRM_DRAIN_IDLE_INTERVAL_MS,
|
||||
debounceMs: env.INBOUND_DEBOUNCE_MS,
|
||||
reapTimeoutMs: env.CRM_EVENT_REAP_TIMEOUT_MS,
|
||||
allowlistTtlMs: env.AI_ALLOWLIST_TTL_DAYS * 24 * 60 * 60 * 1000,
|
||||
},
|
||||
log,
|
||||
loopsAbort.signal,
|
||||
@@ -560,6 +561,7 @@ export async function main(): Promise<void> {
|
||||
followupAi: {
|
||||
...(env.FOLLOWUP_AI_MODEL !== undefined ? { model: env.FOLLOWUP_AI_MODEL } : {}),
|
||||
},
|
||||
allowlistTtlMs: env.AI_ALLOWLIST_TTL_DAYS * 24 * 60 * 60 * 1000,
|
||||
},
|
||||
log,
|
||||
// Onda 5 (Task 5.1): fecha o turno dirigido por fluxo de volta no enrollment —
|
||||
|
||||
@@ -39,6 +39,8 @@ import { logInvocation } from "@/lib/ai/log-invocation";
|
||||
import { elegivelParaWorkerLegado } from "@/lib/ai/agents/no-ar";
|
||||
import { renderSystemPrompt } from "@/lib/ai/render-system-prompt";
|
||||
import { triggerHandoff } from "@/lib/ai/handoff/orchestrator";
|
||||
import { decidirElegibilidadeDaConversaViaSupabase } from "@/lib/ai/elegibilidade/consulta-supabase";
|
||||
import { ttlDaAutorizacaoMs } from "@/lib/ai/elegibilidade/gate";
|
||||
import { checkG1, checkG3, checkG4Legal, checkG4Stage } from "@/lib/ai/handoff/triggers";
|
||||
import type {
|
||||
BotContext,
|
||||
@@ -612,6 +614,30 @@ async function buildContext(input: BuildContextInput): Promise<GuardDecision> {
|
||||
// deterministicamente, mesma família de guard de force_human/bot_silenced_until.
|
||||
if (c.assignee_kind === "user") return skip("assigned_to_human");
|
||||
|
||||
// GATE DE ELEGIBILIDADE (opt-in por canal — `metadata.ai_gate = 'allowlist'`).
|
||||
// Este worker é o caminho PRÉ-ENGINE: responde as orgs sem versão de agente
|
||||
// publicada. Ele TAMBÉM tem de respeitar o gate — senão liga-se
|
||||
// `ai_gate='allowlist'` num canal, o log não reclama, e a IA segue
|
||||
// respondendo todo mundo por aqui (a "falha-em-verde" que a doutrina condena).
|
||||
// Mesma regra pura que o drain e o turno do agent-engine. Canal 'open' (o
|
||||
// default) → `permite:true`, nada muda. Fail-closed: erro de leitura → skip.
|
||||
try {
|
||||
const elegib = await decidirElegibilidadeDaConversaViaSupabase(admin, {
|
||||
organizationId: input.organizationId,
|
||||
conversationId: input.conversationId,
|
||||
agora: new Date(),
|
||||
ttlMs: ttlDaAutorizacaoMs(process.env),
|
||||
});
|
||||
if (elegib !== null && !elegib.permite) {
|
||||
return skip("nao_elegivel_para_ia", elegib.motivo);
|
||||
}
|
||||
} catch (err) {
|
||||
return skip(
|
||||
"nao_elegivel_para_ia",
|
||||
`elegibilidade indeterminada: ${err instanceof Error ? err.message.slice(0, 120) : "erro"}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 24h window (IA-01). Use last_inbound_at — webhook updates it on receive.
|
||||
if (c.last_inbound_at) {
|
||||
const age = Date.now() - new Date(c.last_inbound_at).getTime();
|
||||
|
||||
@@ -18,6 +18,8 @@ import { z } from "zod";
|
||||
|
||||
import { resolverAgenteDaConversa } from "@/lib/ai/agents/agente-da-conversa";
|
||||
import { computeCost } from "@/lib/ai/cost";
|
||||
import { decidirElegibilidadeDaConversaViaSupabase } from "@/lib/ai/elegibilidade/consulta-supabase";
|
||||
import { ttlDaAutorizacaoMs } from "@/lib/ai/elegibilidade/gate";
|
||||
import { DEFAULT_CLASSIFIER_MODEL, isAiGatewayConfigured } from "@/lib/ai/gateway";
|
||||
import { resolverModeloDoPonto } from "@/lib/ai/gateway-binding";
|
||||
import { logInvocation } from "@/lib/ai/log-invocation";
|
||||
@@ -117,6 +119,29 @@ export async function processSentiment(event: EventRow): Promise<SentimentResult
|
||||
return { skipped: true, reason: "empty_body" };
|
||||
}
|
||||
|
||||
// ── Guard: elegibilidade da IA ────────────────────────────────────────
|
||||
// O único efeito deste worker é alimentar o handoff por sentimento
|
||||
// (`ai.sentiment_alert` → `triggerHandoff`). Numa conversa que o gate
|
||||
// `allowlist` barra, `triggerHandoff` já se recusa — então classificar aqui
|
||||
// seria só queimar um Haiku à toa. Pula cedo. `open` (o default) segue.
|
||||
// Fail-closed: erro de leitura → pula (sem custo, sem efeito).
|
||||
const convIdParaGate = conversationId ?? (message.conversation_id as string | null);
|
||||
if (convIdParaGate) {
|
||||
try {
|
||||
const elegib = await decidirElegibilidadeDaConversaViaSupabase(admin, {
|
||||
organizationId: event.organization_id,
|
||||
conversationId: convIdParaGate,
|
||||
agora: new Date(),
|
||||
ttlMs: ttlDaAutorizacaoMs(process.env),
|
||||
});
|
||||
if (elegib !== null && elegib.bloqueioPorAllowlist) {
|
||||
return { skipped: true, reason: "nao_elegivel_para_ia" };
|
||||
}
|
||||
} catch {
|
||||
return { skipped: true, reason: "elegibilidade_indeterminada" };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Qual agente atende ESTA conversa? ─────────────────────────────────
|
||||
//
|
||||
// Antes, a resposta era "o primeiro da organização que atende", ordenado por
|
||||
|
||||
Reference in New Issue
Block a user