mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 01:28:34 +08:00
Merge remote-tracking branch 'refs/remotes/pr/1983' into triagem/1983-janela-de-resposta
This commit is contained in:
@@ -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.
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
@@ -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"). |
|
||||
|
||||
Reference in New Issue
Block a user