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:
Rafael Melgaço
2026-09-03 18:09:16 -03:00
co-authored by Claude Opus 5
53 changed files with 5232 additions and 44 deletions
@@ -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.
+9
View File
@@ -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.
+2
View File
@@ -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
+20
View File
@@ -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 @@
]
}
]
}
}
+81
View File
@@ -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,
+55
View File
@@ -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';
+120
View File
@@ -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');
});
+77
View File
@@ -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
+6
View File
@@ -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>;
+107
View File
@@ -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",
});
}
}
+94
View File
@@ -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"]);
});
});
+112
View File
@@ -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);
}
+62
View File
@@ -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/);
});
});
+73
View File
@@ -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,
}),
);
}
+132
View File
@@ -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);
});
});
+176
View File
@@ -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,
};
}
+28
View File
@@ -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.
+26
View File
@@ -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
View File
@@ -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;
+10
View File
@@ -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,
+66
View File
@@ -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.
*
+13
View File
@@ -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 };
}
+6
View File
@@ -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
+89
View File
@@ -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);
});
});
+129
View File
@@ -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;
}
}
+13
View File
@@ -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).
+101
View File
@@ -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");
});
});
+25
View File
@@ -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,
{
+21 -4
View File
@@ -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
View File
@@ -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\?!/);
});
});
+293
View File
@@ -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);
});
+460
View File
@@ -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);
});
+537
View File
@@ -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 };
+232
View File
@@ -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
View File
@@ -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';
+1
View File
@@ -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");
});
});
+249
View File
@@ -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");
});
});
+10 -1
View File
@@ -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/);
});
});
+2
View File
@@ -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 —
+26
View File
@@ -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();
+25
View File
@@ -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