diff --git a/.changes/janela-de-resposta-24h.md b/.changes/janela-de-resposta-24h.md new file mode 100644 index 000000000..51a408714 --- /dev/null +++ b/.changes/janela-de-resposta-24h.md @@ -0,0 +1,50 @@ +--- +impacto: capacidade_nova +secao: adicionado +titulo: O agente responde a qualquer hora, sem abrir o horário do disparo +--- + +A janela anti-ban era **uma** só, e ela atendia a três coisas: a resposta do +agente, o disparo em massa e a cutucar de conversa parada. Com um único par de +horas, abrir o atendimento para 24 horas abria também o disparo — o que ninguém +pediu e é o caminho mais curto para o número ser banido. + +Agora são **duas janelas**, por número: + +| | antes | agora | +|---|---|---| +| Resposta a quem escreveu | 7h–22h | **0h–24h** | +| Disparos em massa | 7h–22h | 7h–22h (inalterado) | +| Cutucar conversa parada | 7h–22h | 7h–22h (inalterado) | + +O que separa as duas é o tipo do envio: uma reação a uma mensagem recebida lê a +janela de resposta; a cutucar e o disparo leem a janela comercial. + +**O anti-ban continua inteiro.** Abrir o horário não abre o limite: cap diário, +degraus de warm-up por idade do número e o intervalo entre envios seguem valendo +para os dois lados. Um número novo continua com 20 mensagens por dia até completar +o warm-up. + +### ⚠️ Requer atenção + +Atualize a instalação (`update.sh`) para receber as duas colunas novas. A +instalação que não atualizar **continua com o comportamento de sempre** — a +janela de resposta espelha a de disparo até alguém gravar o valor novo. Não há +o que configurar para o sistema não quebrar. + +Para ajustar o horário de cada tipo de envio, grave na tabela `channel_knobs` do +seu número: + +```sql +-- resposta 24h (padrão desta instalação) +update channel_knobs set reengajar_start_hour = 0, reengajar_end_hour = 24 + where organization_id = '' and channel_session_id = ''; + +-- cutucar e disparo em 9h–21h, mais apertado que o padrão +update channel_knobs set window_start_hour = 9, window_end_hour = 21 + where organization_id = '' and channel_session_id = ''; +``` + +Coluna pela metade não abre nada: sem o par completo, vale a janela de disparo. +É de propósito — `reengajar_start_hour = 0` sozinho produziria `0h–22h`, que é +abrir a madrugada pelo caminho que parece conservador. diff --git a/app/api/v1/ai/pacing/route.ts b/app/api/v1/ai/pacing/route.ts index bc09956f9..bb7b76a9e 100644 --- a/app/api/v1/ai/pacing/route.ts +++ b/app/api/v1/ai/pacing/route.ts @@ -29,7 +29,7 @@ import { export const dynamic = "force-dynamic"; const KNOB_COLUMNS = - "throttle_ms, jitter_max_ms, window_start_hour, window_end_hour, allow_sunday, timezone, warmup_daily_caps, number_activated_at"; + // As duas janelas entram no SELECT: sem `reengajar_*` aqui, a ficha Anti-ban\n // mostraria 7h-22h como se fosse a janela da resposta — e é a de DISPARO.\n "throttle_ms, jitter_max_ms, window_start_hour, window_end_hour, reengajar_start_hour, reengajar_end_hour, allow_sunday, timezone, warmup_daily_caps, number_activated_at"; /** * `organizations.timezone`, para a tela mostrar o fuso em que o motor avalia a @@ -163,6 +163,8 @@ export async function PUT(req: NextRequest): Promise { jitter_max_ms: null, window_start_hour: null, window_end_hour: null, + reengajar_start_hour: null, + reengajar_end_hour: null, allow_sunday: null, timezone: null, warmup_daily_caps: null, @@ -178,6 +180,22 @@ export async function PUT(req: NextRequest): Promise { { requestId }, ); } + // A janela da RESPOSTA é validada pelo mesmo par-resultante (0495). Sem isto, + // a tela aceitaria `reengajar_start_hour=22, end=7`, que o motor traduz em + // "nunca responde" — e o operador só descobriria quando o cliente parasse de + // receber resposta, que é o sintoma que ele não consegue ligar para a tela. + // + // `0..24` continua válido: é assim que o dono declara "responde 24h". + if (eff.reengajarStartHour !== 0 || eff.reengajarEndHour !== 24) { + if (!windowIsValid(eff.reengajarStartHour, eff.reengajarEndHour)) { + return fail( + "validation_failed", + `Janela de resposta inválida: início (${eff.reengajarStartHour}h) precisa ser antes do fim (${eff.reengajarEndHour}h). Use 0 e 24 para responder a qualquer hora.`, + 422, + { requestId }, + ); + } + } if (Object.keys(knobFields).length > 0) { const { error: upErr } = await admin.from("channel_knobs").upsert( diff --git a/components/connections/AntiBanSheet.tsx b/components/connections/AntiBanSheet.tsx index 1b11420cf..8c0646470 100644 --- a/components/connections/AntiBanSheet.tsx +++ b/components/connections/AntiBanSheet.tsx @@ -37,6 +37,9 @@ interface Props { interface FormState { window_start_hour: string; window_end_hour: string; + /** Janela da RESPOSTA do agente (0495). '' = herda a janela de disparo. */ + reengajar_start_hour: string; + reengajar_end_hour: string; throttle_s: string; jitter_s: string; daily_message_limit: string; @@ -52,6 +55,8 @@ function fromItem(item: PacingKnobsItem): FormState { return { window_start_hour: o?.window_start_hour != null ? String(o.window_start_hour) : "", window_end_hour: o?.window_end_hour != null ? String(o.window_end_hour) : "", + reengajar_start_hour: o?.reengajar_start_hour != null ? String(o.reengajar_start_hour) : "", + reengajar_end_hour: o?.reengajar_end_hour != null ? String(o.reengajar_end_hour) : "", throttle_s: o?.throttle_ms != null ? String(o.throttle_ms / 1000) : "", jitter_s: o?.jitter_max_ms != null ? String(o.jitter_max_ms / 1000) : "", daily_message_limit: @@ -138,6 +143,8 @@ export function AntiBanSheet({ item, canWrite, onClose }: Props) { channel_session_id: item.channel_session.id, window_start_hour: intOrNull(form.window_start_hour), window_end_hour: intOrNull(form.window_end_hour), + reengajar_start_hour: intOrNull(form.reengajar_start_hour), + reengajar_end_hour: intOrNull(form.reengajar_end_hour), throttle_ms: msOrNull(form.throttle_s), jitter_max_ms: msOrNull(form.jitter_s), // `null` quando o Switch está no default: salvar esta ficha por outro @@ -222,7 +229,44 @@ export function AntiBanSheet({ item, canWrite, onClose }: Props) {
- + +
+ set({ reengajar_start_hour: e.target.value })} + disabled={!canWrite} + aria-label={t("Hora de início da janela de resposta")} + className="w-20" + /> + {t("h até")} + set({ reengajar_end_hour: e.target.value })} + disabled={!canWrite} + aria-label={t("Hora de fim da janela de resposta")} + className="w-20" + /> + h +
+

+ {t( + "Quando o cliente escreve, o agente responde nesta janela. Use 0 e 24 para responder a qualquer hora — o limite anti-ban (teto diário e intervalo entre envios) continua valendo.", + )} +

+
+ +
+
+ j.kind === 'inbound_turn' || j.kind === 'case_reply_turn'; + if (!preview && turnoVaiFalarComOLead(liveJob())) { const { knobs } = await loadChannelKnobs(pool, tenantId, input.channelSessionId, runLog); const agora = clock(); - if (!janelaDeEnvioAberta(agora, knobs)) { - const abertura = proximaAberturaDaJanela(agora, knobs); + const resposta = eResposta(liveJob()); + // `resposta` separa as janelas: reação a quem escreveu lê `reengajar*` + // (0381, por padrão 0h-24h = o dono pediu); cutucar e disparo em massa leem + // `window*` (7h-22h, inalterado). É este o ponto único onde as duas saem. + if (!janelaDeEnvioAberta(agora, knobs, resposta)) { + const abertura = proximaAberturaDaJanela(agora, knobs, resposta); await rescheduleJob(pool, liveJob().id, ctx.workerId, { acquiredAt: claimOfJob(liveJob())?.acquired_at, delayMs: Math.max(abertura.getTime() - agora.getTime(), 1_000), reason: 'fora da janela anti-ban de envio — turno adiado para a abertura', }); runLog.info('turno adiado — fora da janela anti-ban de envio', { - janela: `${knobs.windowStartHour}h-${knobs.windowEndHour}h`, + janela: `${resposta ? knobs.reengajarStartHour : knobs.windowStartHour}h-${resposta ? knobs.reengajarEndHour : knobs.windowEndHour}h`, + tipo: resposta ? 'resposta' : 'cutucar', timezone: knobs.timezone, abertura: abertura.toISOString(), }); @@ -1958,7 +1976,7 @@ async function executarTurnoDoAgente( tenantId, channelSessionId: input.channelSessionId, abertura, - janela: `${knobs.windowStartHour}h-${knobs.windowEndHour}h`, + janela: `${resposta ? knobs.reengajarStartHour : knobs.windowStartHour}h-${resposta ? knobs.reengajarEndHour : knobs.windowEndHour}h`, timezone: knobs.timezone, domingoDesligado: !knobs.allowSunday, }); @@ -2858,6 +2876,9 @@ async function executarTurnoDoAgente( // Só ESTE gate muda; stop, LGPD e pacing continuam valendo integralmente. isTemplate: true, optedOutThisTurn, + // Resposta do turno, mesmo sendo template: quem escreveu espera volta + // a qualquer hora (0381). Só o disparo em massa usa a janela comercial. + resposta: eResposta(liveJob()), crmDailyLimit: null, now: clock(), sleep: deps.sleep, @@ -3072,6 +3093,11 @@ async function executarTurnoDoAgente( channelSessionId: input.channelSessionId, body, optedOutThisTurn, + // `inbound_turn`/`case_reply_turn` respondem a quem escreveu e leem a + // janela de RESPOSTA (0381, 0h-24h nesta instalação). `followup_turn` + // é cutucar de conversa parada e continua na janela de DISPARO: o dono + // pediu responder 24h, nunca puxar conversa. + resposta: eResposta(liveJob()), // ponytail: channel_sessions.daily_message_limit do CRM ainda não é lido // no runtime — null cai nos degraus de warm-up (conservadores). Injetar // aqui quando o drain expuser o limite da sessão. diff --git a/lib/agent-engine/guardrails/before-send.ts b/lib/agent-engine/guardrails/before-send.ts index efd954f85..54ba7419a 100644 --- a/lib/agent-engine/guardrails/before-send.ts +++ b/lib/agent-engine/guardrails/before-send.ts @@ -125,6 +125,20 @@ export interface GateContext { state: PacingState; crmDailyLimit: number | null; rng?: () => number; + /** + * Este envio é RESPOSTA a uma mensagem recebida, ou disparo/cutucar? + * + * ⚠️ OMITIDO = disparo (janela `window*`, 7h-22h). É o default que mantém + * todo chamador que não conhece a 0381 no comportamento antigo, e é a + * direção segura: quem esquece o campo continua preso ao horário comercial + * em vez de abrir o número às 3h. + * + * O `inbound_turn` (cliente escreveu) e o `case_reply_turn` passam `true` e leem + * `reengajar*`. O disparo em massa NÃO passa por este gate — ele usa + * `decidePacing` direto (`lib/prospecting/worker.ts`) — então o valor aqui + * só distingue resposta de cutucar de follow-up. + */ + resposta?: boolean; }; spinning: { knobs: SpinningKnobs; @@ -696,6 +710,7 @@ export const pacingGate: Gate = { state: ctx.pacing.state, crmDailyLimit: ctx.pacing.crmDailyLimit, banRisk, + resposta: ctx.pacing.resposta, rng: ctx.pacing.rng, }); if (!decision.allow) { @@ -894,6 +909,12 @@ export interface RunBeforeSendArgs { * o cap. Ponto de injeção: quando o drain expuser o limite da sessão, passar aqui. */ crmDailyLimit: number | null; + /** + * Este envio é RESPOSTA a uma mensagem recebida (janela `reengajar*`, 0381) ou + * disparo/cutucar (janela `window*`)? OMITIDO = disparo — o default que deixa + * todo chamador anterior à 0381 no comportamento antigo. + */ + resposta?: boolean; now: Date; /** injeções de teste (jitter determinístico + espera sem relógio real). */ rng?: () => number; @@ -1203,6 +1224,7 @@ export async function runBeforeSend(args: RunBeforeSendArgs): Promise number; } @@ -57,16 +70,21 @@ export function decidePacing(input: PacingInput): PacingDecision { const { now, knobs, state, crmDailyLimit } = input; const rng = input.rng ?? Math.random; const banRisk = input.banRisk ?? true; // default preserva o comportamento atual + const resposta = input.resposta ?? false; // default = disparo (janela restritiva) const wall = wallClock(now, knobs.timezone); + // Resposta lê `reengajar*`, disparo lê `window*`. Os knobs comemam sem + // `reengajar*` válido (clone sem a 0381 aplicado) caem no par de disparo — + // é o comportamento de sempre, não um terceiro valor inventado. + const janela = janelaDoPacing(knobs, resposta); - if (!insideWindow(wall, knobs)) { - const nextAllowedAt = addMs(nextWindowOpen(now, knobs), jitterOf(rng, knobs)); + if (!insideWindow(wall, knobs, janela)) { + const nextAllowedAt = addMs(nextWindowOpen(now, knobs, janela), jitterOf(rng, knobs)); return { allow: false, code: 'outside_window', nextAllowedAt, reason: - `fora da janela de envio (${knobs.windowStartHour}h-${knobs.windowEndHour}h` + + `fora da janela de ${resposta ? 'resposta' : 'envio'} (${janela.start}h-${janela.end}h` + `${knobs.allowSunday ? '' : ', sem domingo'}, ${knobs.timezone}); ` + `agende para ${formatInTz(nextAllowedAt, knobs.timezone)} (abertura da janela + jitter)`, }; @@ -197,38 +215,89 @@ export function dayStartInTz(instant: Date, timezone: string): Date { * inteiro em vez de gastar uma chamada de modelo cujo texto o gate vetaria na * saída (ver `inbound-turn.ts`). O gate de envio continua sendo o que decide de * verdade: isto é só o atalho barato, sem tocar em caps nem em throttle. + * + * `resposta` separa as janelas: o turno inbound é RESPOSTA (lê `reengajar*`) e + * o disparo/cutucar é `false` (lê `window*`). Omitir = disparo, que é o + * comportamento de todo chamador anterior a 0381. */ -export function janelaDeEnvioAberta(now: Date, knobs: PacingKnobs): boolean { - return insideWindow(wallClock(now, knobs.timezone), knobs); +export function janelaDeEnvioAberta( + now: Date, + knobs: PacingKnobs, + resposta = false, +): boolean { + return insideWindow(wallClock(now, knobs.timezone), knobs, janelaDoPacing(knobs, resposta)); } /** Próxima abertura da janela + jitter — o instante para o qual se adia. */ export function proximaAberturaDaJanela( now: Date, knobs: PacingKnobs, + resposta = false, rng: () => number = Math.random, ): Date { - return addMs(nextWindowOpen(now, knobs), jitterOf(rng, knobs)); + return addMs( + nextWindowOpen(now, knobs, janelaDoPacing(knobs, resposta)), + jitterOf(rng, knobs), + ); } -function insideWindow(wall: Wall, knobs: PacingKnobs): boolean { +/** Qual janela de [start, end) vale para esta decisão: a da RESPOSTA ou a do DISPARO. */ +function janelaDoPacing( + knobs: PacingKnobs, + resposta: boolean, +): { start: number; end: number } { + if (!resposta) return { start: knobs.windowStartHour, end: knobs.windowEndHour }; + // Par de resposta ausente ou incompleto (clone sem a 0381, ou coluna gravada só + // pela metade): cai na janela de disparo INTEIRA, nunca metade dela. + // + // ⚠️ Misturar as duas colunas (`reengajarStartHour` de uma, `windowEndHour` de + // outra) produziria uma janela que ninguém configurou: com só o início gravado + // como 0, sairia `0h-22h` — que é abrir a madrugada sem ninguém ter pedido, + // pelo caminho que parece mais conservador. Ou `7h-24h`, que abre a noite. + // Qualquer par pela metade é configuração inválida e vale o par completo de + // disparo, que é o comportamento de sempre. + const temInicio = knobs.reengajarStartHour !== undefined && knobs.reengajarStartHour !== null; + const temFim = knobs.reengajarEndHour !== undefined && knobs.reengajarEndHour !== null; + if (!temInicio || !temFim) { + return { start: knobs.windowStartHour, end: knobs.windowEndHour }; + } + return { start: knobs.reengajarStartHour, end: knobs.reengajarEndHour }; +} + +function insideWindow( + wall: Wall, + knobs: PacingKnobs, + janela: { start: number; end: number }, +): boolean { if (!knobs.allowSunday && wall.weekday === 'Sun') return false; - return wall.h >= knobs.windowStartHour && wall.h < knobs.windowEndHour; + return wall.h >= janela.start && wall.h < janela.end; } /** Próxima abertura de janela ESTRITAMENTE depois de `now` (pula domingo se evitado). */ -function nextWindowOpen(now: Date, knobs: PacingKnobs): Date { +function nextWindowOpen( + now: Date, + knobs: PacingKnobs, + janela: { start: number; end: number } = { start: knobs.windowStartHour, end: knobs.windowEndHour }, +): Date { const w = wallClock(now, knobs.timezone); for (let add = 0; ; add += 1) { // Date.UTC normaliza overflow de dia/mês em instantFromWall. - const candidate = instantFromWall(w.y, w.mo, w.d + add, knobs.windowStartHour, knobs.timezone); + const candidate = instantFromWall(w.y, w.mo, w.d + add, janela.start, knobs.timezone); if (candidate.getTime() <= now.getTime()) continue; if (!knobs.allowSunday && wallClock(candidate, knobs.timezone).weekday === 'Sun') continue; return candidate; } } -/** Abertura do PRÓXIMO dia permitido (cap diário reseta na meia-noite local). */ +/** + * Abertura do PRÓXIMO dia permitido (cap diário reseta na meia-noite local). + * + * Usa a janela de DISPARO, não a de resposta, de propósito: cap diário é + * proteção anti-ban e vale para qualquer envio. Se o cap for atingido às 3h por + * causa de uma resposta a quem escreveu, o "amanhã" que se anuncia é o + * reaparecimento do número às 7h, e isso é a única coisa que faz sentido + * dizer a quem pagou. + */ function nextDayOpen(now: Date, knobs: PacingKnobs): Date { const w = wallClock(now, knobs.timezone); for (let add = 1; ; add += 1) { diff --git a/lib/agent-engine/pacing/janela-de-resposta.test.ts b/lib/agent-engine/pacing/janela-de-resposta.test.ts new file mode 100644 index 000000000..b9ab48597 --- /dev/null +++ b/lib/agent-engine/pacing/janela-de-resposta.test.ts @@ -0,0 +1,154 @@ +/** + * A janela de RESPOSTA é diferente da janela de DISPARO (migration 0381). + * + * O dono pediu: responder a qualquer hora, NUNCA disparar nem cortar conversa + * parada fora do horário comercial. Antes da 0381 as duas coisas liam o mesmo + * par de horas de disparo, então abrir o atendimento para 24h abria o disparo + * junto — exatamente o que não foi pedido. + * + * Estes testes existem para travar a SEPARAÇÃO, não o padrão: se alguém voltar + * a ler as horas de DISPARO no caminho da resposta, este arquivo reprova. + */ +import { describe, expect, it } from 'vitest'; + +import { PACING_DEFAULTS, type PacingKnobs } from './defaults'; +import { + decidePacing, + janelaDeEnvioAberta, + proximaAberturaDaJanela, +} from './engine'; + +/** 2026-09-30 é quarta. 03:00 e 21:00 são as horas que separam as janelas. */ +const AS_3H = new Date('2026-09-30T03:00:00-03:00'); // meia-noite-1h em São Paulo +const AS_21H = new Date('2026-09-30T21:00:00-03:00'); +const AS_10H = new Date('2026-09-30T10:00:00-03:00'); + +/** + * A hora local do TENANT, não a da máquina. + * + * `getHours()` devolve a hora no fuso do PROCESSO — e o runner pode ser UTC + * enquanto a janela é avaliada em São Paulo. Foi assim que este arquivo + * "provou" que o adiado era às 12h em vez das 9h: a diferença era o fuso do + * teste, não o do motor. Ler a hora sem o fuso mede o relógio errado. + */ +const TZ = PACING_DEFAULTS.timezone; +const horaNoFuso = (d: Date): number => + Number(new Intl.DateTimeFormat('en-GB', { timeZone: TZ, hour: '2-digit', hour12: false }).format(d)); + +const knobs = (over: Partial = {}): PacingKnobs => ({ + ...PACING_DEFAULTS, + ...over, +}); + +const estado = { lastSentAt: null, sentToday: 0, numberActivatedAt: null }; + +describe('janela de resposta separada da janela de disparo (0381)', () => { + it('a 3h a resposta é liberada e o disparo é barrado', () => { + const k = knobs({ reengajarStartHour: 0, reengajarEndHour: 24 }); + + expect(janelaDeEnvioAberta(AS_3H, k, true)).toBe(true); + expect(janelaDeEnvioAberta(AS_3H, k, false)).toBe(false); + }); + + it('o mesmo número de knobs decide diferente conforme o tipo de envio', () => { + const k = knobs({ reengajarStartHour: 0, reengajarEndHour: 24 }); + + const resposta = decidePacing({ + now: AS_3H, knobs: k, state: estado, crmDailyLimit: null, resposta: true, + }); + const disparo = decidePacing({ + now: AS_3H, knobs: k, state: estado, crmDailyLimit: null, resposta: false, + }); + + expect(resposta.allow).toBe(true); + expect(disparo.allow).toBe(false); + if (!disparo.allow) expect(disparo.code).toBe('outside_window'); + }); + + it('omitir `resposta` é DISPARO — a direção que fecha o número', () => { + const k = knobs({ reengajarStartHour: 0, reengajarEndHour: 24 }); + // O chamador que esquece o campo não pode abrir o número às 3h. + expect(janelaDeEnvioAberta(AS_3H, k)).toBe(false); + expect(decidePacing({ now: AS_3H, knobs: k, state: estado, crmDailyLimit: null }).allow).toBe(false); + }); + + it('dentro do horário comercial os dois são liberados', () => { + const k = knobs({ reengajarStartHour: 0, reengajarEndHour: 24 }); + expect(janelaDeEnvioAberta(AS_10H, k, true)).toBe(true); + expect(janelaDeEnvioAberta(AS_10H, k, false)).toBe(true); + }); + + it('sem `reengajar*` gravado, a resposta herda a janela do disparo', () => { + // Clone que rodou a 0381 sem gravar as colunas: `undefined` cai no par de + // disparo, que é o comportamento de sempre — não vira 0-24 sozinho. + const k = knobs({ reengajarStartHour: undefined as unknown as number, reengajarEndHour: undefined as unknown as number }); + expect(janelaDeEnvioAberta(AS_3H, k, true)).toBe(false); + expect(janelaDeEnvioAberta(AS_10H, k, true)).toBe(true); + }); + + it('par de resposta incompleto (só o início) não abre a janela', () => { + // Gravar só `reengajar_start_hour=0` e deixar o fim vazio é configuração + // pela metade. A resposta NÃO pode virar 0h-infinito por acidente. + const k = knobs({ reengajarStartHour: 0, reengajarEndHour: undefined as unknown as number }); + expect(janelaDeEnvioAberta(AS_3H, k, true)).toBe(false); + }); + + it('o veto da RESPOSTA atrasa para a abertura da resposta, não 7h', () => { + // Resposta com janela própria 9h-21h: fora dela, o adiado é 9h, não o + // `window_start_hour` do disparo. É o que o dono vê no painel. + const k = knobs({ reengajarStartHour: 9, reengajarEndHour: 21 }); + const d = decidePacing({ + now: AS_3H, knobs: k, state: estado, crmDailyLimit: null, resposta: true, rng: () => 0, + }); + expect(d.allow).toBe(false); + if (!d.allow) { + expect(horaNoFuso(d.nextAllowedAt)).toBe(9); + expect(d.reason).toContain('resposta'); + expect(d.reason).toContain('9h-21h'); + } + }); + + it('o veto do DISPARO continua citando a janela de disparo', () => { + const k = knobs({ reengajarStartHour: 0, reengajarEndHour: 24 }); + const d = decidePacing({ + now: AS_3H, knobs: k, state: estado, crmDailyLimit: null, resposta: false, rng: () => 0, + }); + expect(d.allow).toBe(false); + if (!d.allow) { + expect(d.reason).toContain('7h-22h'); + expect(d.reason).not.toContain('0h-24h'); + } + }); + + it('cap diário continua valendo na RESPOSTA — 24h não é sem limite', () => { + // A janela é cortesia; o anti-ban (cap, warm-up, throttle) não abre junto. + const k = knobs({ reengajarStartHour: 0, reengajarEndHour: 24 }); + const d = decidePacing({ + now: AS_3H, knobs: k, state: { ...estado, sentToday: 999 }, crmDailyLimit: null, resposta: true, + }); + expect(d.allow).toBe(false); + if (!d.allow) expect(d.code).not.toBe('outside_window'); + }); + + it('domingo desligado cala a resposta também (knob único, sem par)', () => { + const k = knobs({ reengajarStartHour: 0, reengajarEndHour: 24, allowSunday: false }); + const domingo = new Date('2026-10-04T12:00:00-03:00'); // domingo + expect(janelaDeEnvioAberta(domingo, k, true)).toBe(false); + }); + + it('o padrão do repositório continua espelhando a janela de disparo', () => { + // Se este teste quebrar, todo clone que não gravou `reengajar_*` mudou de + // comportamento sem ninguém pedir. + expect(PACING_DEFAULTS.reengajarStartHour).toBe(PACING_DEFAULTS.windowStartHour); + expect(PACING_DEFAULTS.reengajarEndHour).toBe(PACING_DEFAULTS.windowEndHour); + }); + + it('proximaAberturaDaJanela segue a janela do tipo de envio', () => { + const k = knobs({ reengajarStartHour: 0, reengajarEndHour: 24 }); + // Às 21h, para o disparo só amanhã 7h; para a resposta, amanhã 0h. + const paraDisparo = proximaAberturaDaJanela(AS_21H, k, false, () => 0); + const paraResposta = proximaAberturaDaJanela(AS_21H, k, true, () => 0); + expect(horaNoFuso(paraDisparo)).toBe(7); + expect(horaNoFuso(paraResposta)).toBe(0); + }); +}); \ No newline at end of file diff --git a/lib/agent-engine/pacing/store.ts b/lib/agent-engine/pacing/store.ts index 4e0d5d2f7..14d66b8b2 100644 --- a/lib/agent-engine/pacing/store.ts +++ b/lib/agent-engine/pacing/store.ts @@ -17,6 +17,9 @@ interface ChannelKnobsRow { jitter_max_ms: number | null; window_start_hour: number | null; window_end_hour: number | null; + /** Janela da RESPOSTA do agente (0381). NULL = usa `window_*` (comportamento anterior). */ + reengajar_start_hour: number | null; + reengajar_end_hour: number | null; allow_sunday: boolean | null; timezone: string | null; warmup_daily_caps: unknown; // jsonb — shape validado em parseWarmupCaps (nunca confiado) @@ -92,6 +95,7 @@ export async function loadChannelKnobs( // ainda precisa do fuso da empresa (`fusoDaJanela`). Uma ida ao banco só. const { rows } = await db.query( `select k.throttle_ms, k.jitter_max_ms, k.window_start_hour, k.window_end_hour, + k.reengajar_start_hour, k.reengajar_end_hour, k.allow_sunday, k.timezone, k.warmup_daily_caps, k.number_activated_at, o.timezone as org_timezone from organizations o @@ -124,6 +128,13 @@ export async function loadChannelKnobs( jitterMaxMs: row.jitter_max_ms ?? PACING_DEFAULTS.jitterMaxMs, windowStartHour: row.window_start_hour ?? PACING_DEFAULTS.windowStartHour, windowEndHour: row.window_end_hour ?? PACING_DEFAULTS.windowEndHour, + // `null` nestas duas = o número nunca foi configurado com janela de + // resposta própria, e aí vale a janela de DISPARO. Sem esse `??`, um clone + // que rodou a 0381 porém nunca gravou as colunas teria resposta bloqueada + // fora de 7h-22h (o default do arquivo), que é justamente o que ele já + // fazia — mas por outro caminho, e ninguém saberia dizer qual. + reengajarStartHour: row.reengajar_start_hour ?? row.window_start_hour ?? PACING_DEFAULTS.reengajarStartHour, + reengajarEndHour: row.reengajar_end_hour ?? row.window_end_hour ?? PACING_DEFAULTS.reengajarEndHour, allowSunday: row.allow_sunday ?? PACING_DEFAULTS.allowSunday, timezone: fusoDaJanela(row.timezone, row.org_timezone), warmupDailyCaps, diff --git a/lib/ai/pacing-knobs.ts b/lib/ai/pacing-knobs.ts index 1840eb1b5..b88334edc 100644 --- a/lib/ai/pacing-knobs.ts +++ b/lib/ai/pacing-knobs.ts @@ -113,6 +113,14 @@ export const pacingKnobsUpdateSchema = z jitter_max_ms: z.number().int().min(0).max(KNOB_BOUNDS.intervalMaxMs).nullable().optional(), window_start_hour: z.number().int().min(0).max(KNOB_BOUNDS.hourLastStart).nullable().optional(), window_end_hour: z.number().int().min(1).max(KNOB_BOUNDS.hourEnd).nullable().optional(), + /** + * Janela da RESPOSTA do agente (0381). `0` e `24` são valores LEGÍTIMOS — + * é assim que o dono declara "responde 24h" — então o schema é o mesmo do + * par de disparo, e quem valida start