Merge remote-tracking branch 'refs/remotes/pr/1983' into triagem/1983-janela-de-resposta

This commit is contained in:
melgarafael
2026-09-30 01:58:33 -03:00
13 changed files with 543 additions and 18 deletions
+50
View File
@@ -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 = '<sua org>' and channel_session_id = '<seu número>';
-- 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 = '<sua org>' and channel_session_id = '<seu número>';
```
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.
+19 -1
View File
@@ -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<Response> {
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<Response> {
{ 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(
+45 -1
View File
@@ -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) {
</fieldset>
<fieldset className="flex flex-col gap-2">
<Label>{t("Janela de envio (horário local)")}</Label>
<Label>{t("Janela de RESPOSTA (horário local)")}</Label>
<div className="flex items-center gap-2">
<Input
type="number"
min={0}
max={23}
inputMode="numeric"
placeholder={String(eff.reengajarStartHour)}
value={form.reengajar_start_hour}
onChange={(e) => set({ reengajar_start_hour: e.target.value })}
disabled={!canWrite}
aria-label={t("Hora de início da janela de resposta")}
className="w-20"
/>
<span className="text-sm text-muted-foreground">{t("h até")}</span>
<Input
type="number"
min={1}
max={24}
inputMode="numeric"
placeholder={String(eff.reengajarEndHour)}
value={form.reengajar_end_hour}
onChange={(e) => set({ reengajar_end_hour: e.target.value })}
disabled={!canWrite}
aria-label={t("Hora de fim da janela de resposta")}
className="w-20"
/>
<span className="text-sm text-muted-foreground">h</span>
</div>
<p className="text-xs text-muted-foreground">
{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.",
)}
</p>
</fieldset>
<fieldset className="flex flex-col gap-2">
<Label>{t("Janela de DISPARO (horário local)")}</Label>
<div className="flex items-center gap-2">
<Input
type="number"
+30 -4
View File
@@ -1930,18 +1930,36 @@ async function executarTurnoDoAgente(
// Só a JANELA adia. Cap diário e warm-up continuam com o gate de envio: eles
// dependem de quanto já saiu hoje, e antecipá-los aqui adiaria turno que, na
// hora do envio, teria passado.
// ═══ RESPOSTA vs CUTUCAR — a distinção que a janela de 0381 faz ═══
//
// `turnoVaiFalarComOLead` (guarda acima) admite `followup_turn`, que é a CUTUCAR
// de conversa parada — e cutucar NÃO é responder. O dono foi explícito:
// responder 24h, nunca disparar nem puxar conversa. Abrir `followup_turn` junto
// faria o número mandar "e aí, tudo certo?" às 4h para quem dormiu.
//
// Só a REAÇÃO a uma mensagem recebida vale `resposta: true`. `case_reply_turn`
// entra porque é resposta a um caso em aberto (o atendente/documento), não
// cutucar: ninguém "chama" um caso, o caso chama.
const eResposta = (j: JobRow): boolean =>
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.
@@ -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<BeforeSend
state: pacingState,
crmDailyLimit: args.crmDailyLimit,
rng: args.rng,
...(args.resposta !== undefined ? { resposta: args.resposta } : {}),
},
spinning: { knobs: spinningKnobs, window },
...(args.enforceSpinning === false ? { spinningEnforced: false as const } : {}),
+33 -1
View File
@@ -21,9 +21,35 @@ export interface PacingKnobs {
throttleMs: number;
/** Teto do jitter randômico somado ao throttle e ao next_allowed_at (ms) — intervalo fixo é assinatura de bot. */
jitterMaxMs: number;
/** Janela horária de envio [start, end) na hora local do tenant. */
/**
* Janela horária de DISPARO [start, end) na hora local do tenant — vale para o
* disparo em massa (`lib/prospecting/worker.ts`) e para a cutucar de conversa
* parada (`lib/automation/janela-do-canal.ts`).
*/
windowStartHour: number;
windowEndHour: number;
/**
* Janela horária da RESPOSTA do agente [start, end), na mesma hora local.
*
* ═══ Por que ela é separada ═══
*
* Responder e disparar são riscos diferentes. Disparar 50 mensagens de madrugada
* banina o número; responder UMA pessoa que escreveu às 3h é o serviço, e é o
* que o dono comprou. Com um knob só, abrir o atendimento para 24h abria
* junto o disparo — e o dono pediu exatamente para que NÃO abrisse.
*
* Por isso o `PacingInput` do gate tem `reengajar_*` em vez de mexer na janela
* global: `insideWindow` do disparo continua lendo `window*`, e a RESPOSTA lê
* estes dois. Um canal que nunca gravou as colunas novas (`null` no banco)
* recebe `PACING_DEFAULTS.reengajar*` — que espelham `window*` —, então nenhum
* clone muda de comportamento por omissão.
*
* ⚠️ `allowSunday` NÃO tem par aqui de propósito: domingo liberado é o default
* desde a 0010 e vale para as duas janelas. Se um dia domingo virar knob
* separado, ele pertence aqui, não em `PacingInput`.
*/
reengajarStartHour: number;
reengajarEndHour: number;
/**
* Enviar aos domingos. **Ligado por default** — a janela horária cala à noite,
* e o domingo inteiro mudo era cortesia demais: num CRM de atendimento, quem
@@ -59,6 +85,12 @@ export const PACING_DEFAULTS: PacingKnobs = {
jitterMaxMs: 800,
windowStartHour: 7, // janela 7h-22h
windowEndHour: 22,
// Espelha a janela de disparo: quem nunca gravou as colunas `reengajar_*`
// continua com o comportamento de sempre (a resposta espera fora da janela).
// O dono que QUER 24h grava 0 e 24 no `channel_knobs` — não neste arquivo,
// que é default de fallback, não configuração de instalação.
reengajarStartHour: 7,
reengajarEndHour: 22,
allowSunday: true,
timezone: 'America/Sao_Paulo',
// Número sem linha em channel_knobs é tratado como idade 0 (o degrau mais
+80 -11
View File
@@ -41,6 +41,19 @@ export interface PacingInput {
* Omitir = `true`: nenhum chamador existente muda de comportamento.
*/
banRisk?: boolean;
/**
* Esta decisão é para a RESPOSTA do agente (o cliente escreveu e espera
* resposta) ou para o DISPARO (envio em massa / cutucar)?
*
* ⚠️ `true` (resposta) lê `reengajar*`; `false`/omitido (disparo) lê
* `window*`. Default `false` porque TODO chamador existente é disparo ou
* não-POSTO — o `pacingGate` precisa declarar a resposta explicitamente para
* a separação valer, e é o que a torna visível numa revisão de código.
*
* Responder e disparar são riscos diferentes: 50 mensagens de madrugada
* baninam o número, uma resposta para quem escreveu às 3h é o serviço.
*/
resposta?: boolean;
/** [0,1) — injetável nos testes; default Math.random. */
rng?: () => 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) {
@@ -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> = {}): 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);
});
});
+11
View File
@@ -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<ChannelKnobsRow>(
`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,
+16
View File
@@ -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<end é `windowIsValid` sobre o par
* RESULTANTE, depois de mesclar com o que já está gravado.
*/
reengajar_start_hour: z.number().int().min(0).max(KNOB_BOUNDS.hourLastStart).nullable().optional(),
reengajar_end_hour: z.number().int().min(1).max(KNOB_BOUNDS.hourEnd).nullable().optional(),
allow_sunday: z.boolean().nullable().optional(),
timezone: z
.string()
@@ -163,6 +171,9 @@ export interface ChannelKnobsRow {
jitter_max_ms: number | null;
window_start_hour: number | null;
window_end_hour: number | null;
/** Janela da RESPOSTA (0381). Ausente/null = herda a janela de disparo. */
reengajar_start_hour?: number | null;
reengajar_end_hour?: number | null;
allow_sunday: boolean | null;
timezone: string | null;
warmup_daily_caps: unknown;
@@ -191,6 +202,11 @@ export function effectiveKnobs(row: ChannelKnobsRow | null, fusoDaOrg?: string |
jitterMaxMs: row?.jitter_max_ms ?? PACING_DEFAULTS.jitterMaxMs,
windowStartHour: row?.window_start_hour ?? PACING_DEFAULTS.windowStartHour,
windowEndHour: row?.window_end_hour ?? PACING_DEFAULTS.windowEndHour,
// Mesma regra do store do engine: coluna vazia herda a janela de DISPARO.
// A tela mostra `null` como "Usar o padrão" — e o padrão É o par de disparo,
// então mostrar 7h-22h aqui não mente: é o que o motor vai aplicar.
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, fusoDaOrg),
warmupDailyCaps: parseWarmupCaps(row?.warmup_daily_caps) ?? PACING_DEFAULTS.warmupDailyCaps,
+41
View File
@@ -44544,6 +44544,47 @@ forçado. Inventar é pior do que demorar um instante a mais para responder.
end
$pub$;
-- ---- janela de RESPOSTA separada da janela de DISPARO (migration 0495) ----
-- O dono pediu: o agente responde a qualquer hora, mas NADA de disparo em massa
-- nem cutucar conversa parada fora do horário comercial. As duas coisas eram
-- regidas por UM knob (`window_start_hour`/`window_end_hour`), então abrir a
-- janela do agente para 24h abriria também a do disparo.
--
-- Colunas soltas, não jsonb: `window_*_hour` é coluna desde a 0010 e a tela de
-- Conexões já os edita; um `reengajar_knobs` jsonb nasceria sem CHECK forte e
-- divergiria do vizinho na mesma tabela.
--
-- ⚠️ NULL = conserva o comportamento de HOJE (o agente espera na janela do
-- disparo). Um clone que nunca gravou estas colunas não muda de comportamento por
-- causa desta migration — e é por isso que o default é NULL e não 0/24.
--
-- O padrão DOCUMENTADO de `reengajar_*` é 9h-21h, mais apertado que o disparo
-- (7h-22h) porque cutucar quem SUMIU é o que mais incomoda: essa mensagem chega
-- para alguém que não pediu nada. Quem preferir igualar ao disparo grava 7 e 22.
alter table public.channel_knobs
add column if not exists reengajar_start_hour smallint,
add column if not exists reengajar_end_hour smallint;
comment on column public.channel_knobs.reengajar_start_hour is
'Início da janela de RESPOSTA do agente (h, hora local da org). NULL = usa window_start_hour (comportamento anterior).';
comment on column public.channel_knobs.reengajar_end_hour is
'Fim da janela de RESPOSTA do agente (h, exclusivo; 24 = meia-noite). NULL = usa window_end_hour.';
-- 0..24. `end` pode ser 24 (meia-noite seguinte) porque `insideWindow` compara
-- `wall.h < windowEndHour` e a hora local nunca passa de 23.
-- ⚠️ O `drop … if exists` ANTES do `add` é o que torna isto reaplicável: o
-- `update.sh` roda o apêndice inteiro em toda atualização, e `add constraint`
-- sem guarda quebra com 'already exists' no segundo clone que atualizar. É o
-- gate `tests/unit/baseline-reaplicavel.test.ts` que cobra esta forma.
alter table public.channel_knobs
drop constraint if exists channel_knobs_reengajar_horas_validas;
alter table public.channel_knobs
add constraint channel_knobs_reengajar_horas_validas
check (
(reengajar_start_hour is null or reengajar_start_hour between 0 and 23)
and (reengajar_end_hour is null or reengajar_end_hour between 1 and 24)
);
-- ---- dedupe de event_dead atômico: índice único parcial (migration 0491) ----
-- 0491 — o aviso `event_dead` não abre em dobro com dois drenos concorrentes
-- (issue #880). O dedupe era uma pergunta seguida de uma escrita: `lib/event-log/
@@ -0,0 +1,41 @@
-- ═══ Janela de RESPOSTA separada da janela de DISPARO (0495) ═══
--
-- O dono pediu: o agente responde a qualquer hora do dia, mas NADA de disparo
-- em massa nem cutucar conversa parada fora do horário comercial. As duas coisas
-- regidas por UM knob (`window_start_hour`/`window_end_hour`), então abrir a
-- janela do agente para 24h abriria também a do disparo.
--
-- Por que colunas soltas e não um jsonb: o projeto trata `window_*_hour` como
-- coluna desde a 0010 e a tela de Conexões já os edita. `reengajar_knobs`
-- nasceria jsonb sem CHECK forte e divergiria do vizinho na mesma tabela.
--
-- O DEFAULT de `reengajar_start_hour` é 9 e `reengajar_end_hour` é 21 — mais
-- apertado que o disparo (7h–22h) porque cutucar quem SUMIU é o que mais
-- incomoda: essa mensagem chega para alguém que não pediu nada. Quem preferir
-- igualar ao disparo grava 7 e 22.
--
-- ⚠️ NULL = conserva o comportamento de HOJE (o agente espera na janela do
-- disparo). Um clone que nunca gravou estas colunas não muda de comportamento
-- por causa desta migration — e é por isso que o default é NULL e não 0/24.
alter table channel_knobs
add column if not exists reengajar_start_hour smallint,
add column if not exists reengajar_end_hour smallint;
comment on column channel_knobs.reengajar_start_hour is
'Início da janela de RESPOSTA do agente (h, hora local da org). NULL = usa window_start_hour (comportamento anterior).';
comment on column channel_knobs.reengajar_end_hour is
'Fim da janela de RESPOSTA do agente (h, exclusivo; 24 = meia-noite). NULL = usa window_end_hour.';
-- 0..24. `end` pode ser 24 (meia-noite seguinte) porque `insideWindow` compara
-- `wall.h < windowEndHour` e a hora local nunca passa de 23.
-- O `drop … if exists` antes do `add` e o que torna a migration reaplicavel:
-- o `update.sh` de quem ja aplicou a 0495 roda o apendice do baseline de novo, e
-- `add constraint` sem guarda quebra com 'already exists'. Mesmo par no baseline.
alter table channel_knobs
drop constraint if exists channel_knobs_reengajar_horas_validas;
alter table channel_knobs
add constraint channel_knobs_reengajar_horas_validas
check (
(reengajar_start_hour is null or reengajar_start_hour between 0 and 23)
and (reengajar_end_hour is null or reengajar_end_hour between 1 and 24)
);
+1
View File
@@ -483,3 +483,4 @@ To re-apply on a fresh Supabase project, replay the migrations in version order
| `20260929020337` | `0486_o_playbook_da_agenda_aprende_os_dois_passos` | **Playbook `agendamento` ensina os dois passos da cadeia de agenda (#1019).** O corpo semeado pela 0191 começava o meio da cadeia: mandava consultar `crm_find_free_slots` sem dizer de onde vem o `event_type_slug` que ela EXIGE, e nenhuma linha do playbook nomeava `crm_list_event_types` (medido: zero ocorrências em `supabase/`). Publica a v3 com o passo 1 (lista → `slug`), o passo 2 (horários com o `event_type_slug` da lista) e a marcação (`crm_book_appointment` com o `starts_at` devolvido), todo nome de ferramenta DENTRO de uma condição — o playbook é org-wide e não sabe quais capacidades o agente tem. Mesma forma da 0191: md5 conferido no insert, reponte SEMPRE. Catraca: `tests/unit/playbook-cita-a-ferramenta.test.ts` passa a cobrar `crm_list_event_types`. |
| `20260929115848` | `0491_dedupe_de_event_dead_atomico` | **O aviso `event_dead` não abre em dobro com dois drenos concorrentes (#880).** O dedupe era pergunta e escrita separadas: `lib/event-log/drain.ts` consulta se já existe aviso aberto e só depois insere, e o `insert … where not exists` de `insertInboxItem` (`lib/agent-engine/db/repository.ts`) não tinha índice nenhum que sustentasse a condição — o cron `event-log-drain` e o drain-loop do worker rodam `drainEventLog` no mesmo instante e os dois leem "não existe" antes de qualquer escrita. Índice único parcial `agent_inbox_event_dead_aberto_unico (organization_id, kind, title) where status = 'open' and kind = 'event_dead'`: a chave leva o TÍTULO porque as duas famílias do `event_dead` (a da IA que deixou de responder e a de mídia/automação, `lib/event-log/aviso-de-evento-morto.ts`) precisam conviver abertas na mesma organização, e o predicado é PARCIAL porque `status` na chave guardaria uma linha resolvida para sempre e mataria a reabertura. Escopo só `event_dead` — os outros dedupes desta tabela são por ref e querem várias linhas abertas com o mesmo título, uma por conversa ou por lead. Prévia resolve as cópias abertas repetidas (só a mais antiga fica aberta; nunca delete, régua da 0064). `insertInboxItem` e o dreno passam a tratar `23505` como "já havia um aviso aberto" — devolve `null` e não loga erro —, e a reabertura para um slot que já tem um aberto igual volta `409` em vez de `500` (`app/api/v1/ai/inbox/[id]/route.ts`). Idempotente nas duas pontas. Gate: `tests/unit/aviso-event-dead-concorrente-abre-uma-vez.test.ts`. |
| `20260930010300` | `0494_lgpd_cascata_banco_alcanca_notas_itens` | **A cascata do BANCO alcança `lead_notes`, `ai_agent_runs.tool_calls`, `lead_state` e `contacts.social_identity` (issue #1964, follow-up do #1958).** O gatilho da virada de `is_anonymized` (desenho da 0391, a porta que os DOIS caminhos de anonimização cruzam) passou a redigir também: `lead_notes.headline`/`body` (e `embedding`) → `(anonimizado)`, `ai_agent_runs.tool_calls` → redação que PRESERVA o nome da ferramenta e apaga argumentos/resultados (espelha `redigirToolCalls`/`serialize.ts`, com `redacted = true` em todo passo), `lead_state.next_action` → `null` e `qualification` → `{}`, e `contacts.social_identity` → `null` (o índice único parcial `where social_identity is not null` torna anular seguro). Filtra organização **e** contato em todo passo; idempotente — guard no WHERE repete o marcador de "já redigido" da app para a varredura diária não reescrever o que já está anonimizado. `fn_lgpd_redigir_tool_calls(jsonb)` é função nova `immutable` revogada de `anon`/`authenticated` (único chamador é o gatilho, `security definer` de dono postgres). Sem cura retroativa no corpo (a varredura diária da app já cura o que veio antes); apêndice idempotente no `baseline.sql` antes da VARREDURA anon. Gate: `tests/invariants/lgpd-cascata-do-banco-alcanca-notas-itens.test.ts`. |
| `20260930120000` | `0495_janela_de_resposta_separada` | **A janela de RESPOSTA sai da janela de DISPARO: o agente passa a responder 24h sem abrir o horário do disparo.** As duas coisas eram regidas por UM par de horas em `channel_knobs` (`window_start_hour`/`window_end_hour`, da 0010), então abrir o atendimento para 24h abria junto o disparo em massa — e `followup_turn` (a cutucar de conversa parada), que é o tipo de envio que mais incomoda quando sai de hora. Duas colunas nullable, `reengajar_start_hour`/`reengajar_end_hour`: **NULL = a resposta herda a janela de disparo**, que é o comportamento anterior — um clone que aplicou a 0495 sem gravá-las não muda de operação por omissão (e é por isso que o default é NULL e não 0/24). CHECK 0..23 / 1..24, com `drop … if exists` antes do `add` nos DOIS arquivos porque o `update.sh` reaplica o apêndice inteiro e `add constraint` sem guarda quebra com 'already exists' no segundo clone que atualizar (gate `baseline-reaplicavel`). Colunas soltas e não jsonb: `window_*_hour` é coluna desde a 0010 e a ficha Anti-ban já os edita. A separação acontece em `PacingInput.resposta` (default `false` = disparo: todo chamador anterior à 0495 fica no horário comercial, e é a direção que fecha o número), lido pelo turno inbound (`inbound_turn`/`case_reply_turn` respondem; `followup_turn` continua na janela comercial) e pelo `pacingGate`. **`nextDayOpen` segue na janela de DISPARO de propósito**: cap diário é proteção anti-ban, e o "amanhã" que se anuncia a quem pagou às 3h é o número de novo às 7h. Par pela metade é tratado como ausente — misturar `reengajar_start_hour=0` com `window_end_hour=22` daria `0h-22h`, que abre a madrugada pelo caminho que parece conservador. O anti-ban continua inteiro: cap diário, degraus de warm-up e throttle rodam para os dois lados. Apêndice idêntico no `baseline.sql`. Gates: `lib/agent-engine/pacing/janela-de-resposta.test.ts` (12 casos: às 3h a resposta passa e o disparo não; `resposta` omitido barra; par incompleto não abre; cap diário continua barrando; domingo continua calando a resposta) e `tests/unit/pacing-knobs.test.ts` + `tests/unit/protecao-de-envio-aceita-data-em-branco.test.ts` (a ficha AntiBanSheet e o PUT de `/api/v1/ai/pacing` editam e validam o par novo, inclusive `0`/`24` como "responde a qualquer hora"). |