Merge remote-tracking branch 'origin/main' into feat/org-operante

This commit is contained in:
melgarafael
2026-09-30 13:09:33 -03:00
51 changed files with 2555 additions and 29 deletions
@@ -0,0 +1,7 @@
---
impacto: nada_mudou
secao: corrigido
titulo: O agente respeita os funis autorizados ao atualizar a etapa de um negócio
---
Ao confirmar uma etapa durante a conversa, o agente agora passa ao espelho do CRM os funis autorizados em sua versão publicada. Um agente sem acesso ao funil não move mais o cartão, inclusive quando retoma um caso interno; agentes com o funil autorizado continuam podendo atualizar a etapa. O estado da conversa e a resposta ao cliente seguem seu fluxo normal.
+25 -1
View File
@@ -8,6 +8,28 @@ Se você roda o DeskcommCRM numa VPS, **leia a seção da versão para a qual es
## [Não lançado]
## [1.67.0] — 2026-09-30
### Adicionado
- **O tempo que o agente espera antes de responder passa a ser ajustável por número** A proteção de envio de cada número (em Conexões › Proteção de envio) ganhou quatro campos para o "tempo de pensar" do agente antes da primeira mensagem da resposta: o tempo para ver a notificação, o tempo por caractere digitado, o mínimo e o máximo. Campo vazio mantém exatamente o ritmo de antes (0,9 s + 22 ms por caractere, entre 1,2 s e 7,5 s). Um mínimo acima do máximo é recusado na hora, com a explicação na tela.
Quando a resposta sai em várias mensagens, o intervalo entre elas passa a seguir o "intervalo entre mensagens" configurado para o número, em vez de um valor fixo de 1,2 s a 2 s. Quem nunca mudou esse intervalo não nota diferença; quem mudou vê as mensagens da resposta seguindo o valor escolhido.
Não é preciso fazer nada na instalação: a atualização acrescenta as colunas sozinha.
Contribuição de @webtecnica (#1996), fechando a #653.
- **Espera antes de responder (janela de rajada) configurável por agente** No cadastro do agente, em "Freios de segurança", agora dá para ajustar quanto tempo o agente espera antes de responder mensagens do mesmo contato que chegam em partes (ex.: "Obrigada" e, 14 s depois, "Tem piscina?"). Deixar o campo vazio usa a janela padrão da instalação (8 s), e o valor máximo é 60 segundos — nada de travar o atendimento sem querer. Não muda nada para quem não mexer: o campo é opcional e o comportamento de antes (janela padrão da instalação) segue valendo quando ele está vazio.
Contribuição de @webtecnica (#1997).
## [1.66.1] — 2026-09-30
### Corrigido
- **Anonimizar um contato passa a apagar também a transcrição dos áudios e o texto lido das imagens dele** Quando um contato era anonimizado (LGPD), o texto das mensagens virava "[mensagem anonimizada]", mas a transcrição automática dos áudios e o texto lido das imagens e documentos continuavam guardados e legíveis, inclusive para o agente de IA. Agora a anonimização apaga esse texto junto, pelo pedido formal e pelo botão da ficha, e a atualização limpa também os contatos que já tinham sido anonimizados antes. Não é preciso fazer nada na instalação. Achado na triagem do #1988 (@AlecLimaDev).
## [1.66.0] — 2026-09-30
### Adicionado
@@ -9639,7 +9661,9 @@ Primeira versão marcada do DeskcommCRM. O projeto vinha sendo desenvolvido publ
- **Node 22 é obrigatório para desenvolvimento.** A suíte de invariantes instancia o cliente do Supabase, que exige o `WebSocket` global — nativo apenas a partir do Node 22. Isso não afeta quem apenas hospeda: a VPS roda a imagem pronta.
[Não lançado]: https://github.com/melgarafael/DeskcommCRM/compare/v1.66.0...HEAD
[Não lançado]: https://github.com/melgarafael/DeskcommCRM/compare/v1.67.0...HEAD
[1.67.0]: https://github.com/melgarafael/DeskcommCRM/compare/v1.66.1...v1.67.0
[1.66.1]: https://github.com/melgarafael/DeskcommCRM/compare/v1.66.0...v1.66.1
[1.66.0]: https://github.com/melgarafael/DeskcommCRM/compare/v1.65.0...v1.66.0
[1.65.0]: https://github.com/melgarafael/DeskcommCRM/compare/v1.64.1...v1.65.0
[1.64.1]: https://github.com/melgarafael/DeskcommCRM/compare/v1.64.0...v1.64.1
@@ -19,7 +19,7 @@ import { traduzir } from "@/lib/i18n/dicionario";
export const dynamic = "force-dynamic";
const VERSION_COLUMNS =
"id, organization_id, agent_id, version_number, system_prompt, provider, model, credential_id, tool_ids, trigger_config, channel_session_id, max_steps, token_budget, cost_budget_cents, history_message_window, history_token_window, handoff_keywords, handoff_tool_enabled, proposal_ai_draft_enabled, cases_enabled, split_messages, split_max_chars, followup, operator_enabled, operator_model, operator_tool_ids, status, published_at, superseded_at, created_at, created_by,pipeline_ids,knowledge_source_ids,provisioning_origin";
"id, organization_id, agent_id, version_number, system_prompt, provider, model, credential_id, tool_ids, trigger_config, channel_session_id, max_steps, token_budget, cost_budget_cents, history_message_window, history_token_window, handoff_keywords, handoff_tool_enabled, proposal_ai_draft_enabled, cases_enabled, split_messages, split_max_chars, followup, operator_enabled, operator_model, operator_tool_ids, status, published_at, superseded_at, created_at, created_by,pipeline_ids,knowledge_source_ids,provisioning_origin,inbound_debounce_ms";
const UUID_RX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@@ -124,6 +124,7 @@ export async function PATCH(req: NextRequest, ctx: Ctx): Promise<Response> {
if (patch.cases_enabled !== undefined) update.cases_enabled = patch.cases_enabled;
if (patch.split_messages !== undefined) update.split_messages = patch.split_messages;
if (patch.split_max_chars !== undefined) update.split_max_chars = patch.split_max_chars;
if (patch.inbound_debounce_ms !== undefined) update.inbound_debounce_ms = patch.inbound_debounce_ms;
if (patch.followup !== undefined) {
const existingFollowup =
existing.followup !== null &&
+2 -1
View File
@@ -23,7 +23,7 @@ import { traduzir } from "@/lib/i18n/dicionario";
export const dynamic = "force-dynamic";
const VERSION_COLUMNS =
"id, organization_id, agent_id, version_number, system_prompt, provider, model, credential_id, tool_ids, trigger_config, channel_session_id, max_steps, token_budget, cost_budget_cents, history_message_window, history_token_window, handoff_keywords, handoff_tool_enabled, proposal_ai_draft_enabled, cases_enabled, split_messages, split_max_chars, followup, operator_enabled, operator_model, operator_tool_ids, status, published_at, superseded_at, created_at, created_by,pipeline_ids,knowledge_source_ids,provisioning_origin";
"id, organization_id, agent_id, version_number, system_prompt, provider, model, credential_id, tool_ids, trigger_config, channel_session_id, max_steps, token_budget, cost_budget_cents, history_message_window, history_token_window, handoff_keywords, handoff_tool_enabled, proposal_ai_draft_enabled, cases_enabled, split_messages, split_max_chars, followup, operator_enabled, operator_model, operator_tool_ids, status, published_at, superseded_at, created_at, created_by,pipeline_ids,knowledge_source_ids,provisioning_origin,inbound_debounce_ms";
const UUID_RX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@@ -163,6 +163,7 @@ export async function POST(req: NextRequest, ctx: Ctx): Promise<Response> {
cases_enabled: v.cases_enabled,
split_messages: v.split_messages,
split_max_chars: v.split_max_chars,
inbound_debounce_ms: v.inbound_debounce_ms,
followup: v.followup,
// Mesmo descarte silencioso da rota de criação de agente: o corpo aceita
// e o INSERT ignorava. Criar uma versão nova pela API com escopo ou
+1 -1
View File
@@ -48,7 +48,7 @@ const AGENT_COLUMNS_COM_VERSAO =
", versao_publicada:ai_agent_versions!ai_agents_published_version_id_fkey(provider, model)";
const VERSION_COLUMNS =
"id, organization_id, agent_id, version_number, system_prompt, provider, model, credential_id, tool_ids, trigger_config, channel_session_id, max_steps, token_budget, cost_budget_cents, history_message_window, history_token_window, handoff_keywords, handoff_tool_enabled, proposal_ai_draft_enabled, cases_enabled, split_messages, split_max_chars, followup, operator_enabled, operator_model, operator_tool_ids, status, published_at, superseded_at, created_at, created_by,pipeline_ids,knowledge_source_ids,provisioning_origin";
"id, organization_id, agent_id, version_number, system_prompt, provider, model, credential_id, tool_ids, trigger_config, channel_session_id, max_steps, token_budget, cost_budget_cents, history_message_window, history_token_window, handoff_keywords, handoff_tool_enabled, proposal_ai_draft_enabled, cases_enabled, split_messages, split_max_chars, followup, operator_enabled, operator_model, operator_tool_ids, status, published_at, superseded_at, created_at, created_by,pipeline_ids,knowledge_source_ids,provisioning_origin,inbound_debounce_ms";
// ---------------------------------------------------------------------------
// GET — list
+16 -1
View File
@@ -31,7 +31,7 @@ export const dynamic = "force-dynamic";
const KNOB_COLUMNS =
// As duas janelas entram no SELECT: sem `resposta_*` aqui, a ficha Anti-ban
// mostraria 7h-22h como se fosse a janela da resposta — e é a de DISPARO.
"throttle_ms, jitter_max_ms, window_start_hour, window_end_hour, resposta_start_hour, resposta_end_hour, allow_sunday, timezone, warmup_daily_caps, number_activated_at";
"throttle_ms, jitter_max_ms, window_start_hour, window_end_hour, resposta_start_hour, resposta_end_hour, allow_sunday, timezone, warmup_daily_caps, number_activated_at, atraso_notar_ms, ms_por_caractere, atraso_minimo_ms, atraso_maximo_ms";
/**
* `organizations.timezone`, para a tela mostrar o fuso em que o motor avalia a
@@ -167,6 +167,10 @@ export async function PUT(req: NextRequest): Promise<Response> {
window_end_hour: null,
resposta_start_hour: null,
resposta_end_hour: null,
atraso_notar_ms: null,
ms_por_caractere: null,
atraso_minimo_ms: null,
atraso_maximo_ms: null,
allow_sunday: null,
timezone: null,
warmup_daily_caps: null,
@@ -195,6 +199,17 @@ export async function PUT(req: NextRequest): Promise<Response> {
{ requestId },
);
}
// Atraso humano (0499), pelo mesmo par-resultante: com os dois gravados o
// CHECK do banco recusaria e a tela leria um 500 genérico; com só o mínimo
// acima do teto padrão, o clamp ignoraria o mínimo sem aviso.
if (eff.atrasoMinimoMs > eff.atrasoMaximoMs) {
return fail(
"validation_failed",
`Atraso humano inválido: o mínimo (${eff.atrasoMinimoMs} ms) precisa ser menor ou igual ao máximo (${eff.atrasoMaximoMs} ms).`,
422,
{ requestId },
);
}
if (Object.keys(knobFields).length > 0) {
const { error: upErr } = await admin.from("channel_knobs").upsert(
+5 -1
View File
@@ -38,7 +38,7 @@ import { VALID_TOOL_IDS } from "@/lib/mcp/tools";
const UUID_RX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const VERSION_COLUMNS =
"id, organization_id, agent_id, version_number, system_prompt, provider, model, credential_id, tool_ids, trigger_config, channel_session_id, max_steps, token_budget, cost_budget_cents, history_message_window, history_token_window, handoff_keywords, handoff_tool_enabled, proposal_ai_draft_enabled, cases_enabled, split_messages, split_max_chars, followup, operator_enabled, operator_model, operator_tool_ids, status, published_at, superseded_at, created_at, created_by,pipeline_ids,knowledge_source_ids,provisioning_origin";
"id, organization_id, agent_id, version_number, system_prompt, provider, model, credential_id, tool_ids, trigger_config, channel_session_id, max_steps, token_budget, cost_budget_cents, history_message_window, history_token_window, handoff_keywords, handoff_tool_enabled, proposal_ai_draft_enabled, cases_enabled, split_messages, split_max_chars, followup, operator_enabled, operator_model, operator_tool_ids, status, published_at, superseded_at, created_at, created_by,pipeline_ids,knowledge_source_ids,provisioning_origin,inbound_debounce_ms";
type ActionResult<T = void> =
| { ok: true; data?: T }
@@ -320,6 +320,7 @@ export async function saveAgentDraftAction(
knowledge_source_ids: v.knowledge_source_ids,
split_messages: v.split_messages,
split_max_chars: v.split_max_chars,
inbound_debounce_ms: v.inbound_debounce_ms ?? null,
followup: v.followup,
status: "draft",
created_by: authUser.id,
@@ -537,6 +538,7 @@ export async function revertToVersionAction(
knowledge_source_ids: string[];
split_messages: boolean;
split_max_chars: number;
inbound_debounce_ms: number | null;
};
const src = source as unknown as SourceRow;
@@ -586,6 +588,7 @@ export async function revertToVersionAction(
knowledge_source_ids: src.knowledge_source_ids ?? [],
split_messages: src.split_messages,
split_max_chars: src.split_max_chars,
inbound_debounce_ms: src.inbound_debounce_ms ?? null,
status: "draft",
created_by: authUser.id,
})
@@ -739,6 +742,7 @@ export async function createMcpAgentAction(
cases_enabled: v.cases_enabled,
split_messages: v.split_messages,
split_max_chars: v.split_max_chars,
inbound_debounce_ms: v.inbound_debounce_ms ?? null,
// O corpo ACEITAVA estes cinco e o INSERT os descartava: criar o assistente
// pela tela com papel Operador, escopo de funil ou material marcado produzia
// uma versão com tudo no default do banco — desligado e vazio.
@@ -184,6 +184,8 @@ interface FormState {
cases_enabled: boolean;
split_messages: boolean;
split_max_chars: number;
/** Janela de rajada (ms) do agente. `null` = usa a env da instalação. */
inbound_debounce_ms: number | null;
followup: FollowupValue;
// Papel OPERADOR (spec 16 §3.2) — o que mexe no sistema depois da conversa.
operator_enabled: boolean;
@@ -284,6 +286,7 @@ export function buildState(args: {
cases_enabled: version?.cases_enabled ?? false,
split_messages: version?.split_messages ?? false,
split_max_chars: version?.split_max_chars ?? 600,
inbound_debounce_ms: version?.inbound_debounce_ms ?? null,
followup: version?.followup
? {
...DEFAULT_FOLLOWUP,
@@ -347,6 +350,7 @@ function toVersionPayload(s: FormState) {
cases_enabled: s.cases_enabled,
split_messages: s.split_messages,
split_max_chars: s.split_max_chars,
inbound_debounce_ms: s.inbound_debounce_ms,
followup: s.followup,
operator_enabled: s.operator_enabled,
// "" (não escolheu) → null (herda o do Conversador). São o mesmo conceito em
@@ -1060,6 +1064,42 @@ export function AgentForm(props: Props) {
disabled={disabled}
/>
</div>
<div className="col-span-2 space-y-1">
<Label htmlFor="inbound_debounce_ms">
{t("Esperar antes de responder (segundos)")}
</Label>
<Input
id="inbound_debounce_ms"
type="number"
min={0}
max={60}
step={1}
placeholder={t("Vazio = padrão da instalação")}
value={
form.inbound_debounce_ms === null
? ""
: String(Math.round(form.inbound_debounce_ms / 1000))
}
onChange={(e) => {
const raw = e.target.value;
// Vazio = usa a env da instalação (campo null). A UI fala em
// SEGUNDOS; o banco e o worker falam em ms (conversão aqui,
// num ponto só). Teto de 60s no campo espelha o do worker.
patch({
inbound_debounce_ms:
raw === ""
? null
: Math.round(Math.max(0, Math.min(60, Number(raw))) * 1000),
});
}}
disabled={disabled}
/>
<p className="text-xs text-muted-foreground">
{t(
"Mensagens do mesmo contato dentro desse tempo viram uma resposta só. Vazio usa a janela padrão da instalação (máximo 60 segundos).",
)}
</p>
</div>
</div>
</Card>
</div>
@@ -91,6 +91,7 @@ function buildFieldChanges(a: AgentVersionRow, b: AgentVersionRow): FieldChange[
["cases_enabled", "cases_enabled"],
["split_messages", "split_messages"],
["split_max_chars", "split_max_chars"],
["inbound_debounce_ms", "inbound_debounce_ms"],
];
return fields
.filter(([k]) => a[k] !== b[k])
+1 -1
View File
@@ -26,7 +26,7 @@ const AGENT_COLUMNS =
"id, organization_id, name, description, model, system_prompt, is_active, is_default, kind, channel, priority, published_version_id, paused_at, operation_mode, operation_revision, archived_at, config, guardrails, active_kb_version_id, created_at, updated_at";
const VERSION_COLUMNS =
"id, organization_id, agent_id, version_number, system_prompt, provider, model, credential_id, tool_ids, trigger_config, channel_session_id, max_steps, token_budget, cost_budget_cents, history_message_window, history_token_window, handoff_keywords, handoff_tool_enabled, proposal_ai_draft_enabled, cases_enabled, split_messages, split_max_chars, followup, operator_enabled, operator_model, operator_tool_ids, status, published_at, superseded_at, created_at, created_by,pipeline_ids,knowledge_source_ids,provisioning_origin";
"id, organization_id, agent_id, version_number, system_prompt, provider, model, credential_id, tool_ids, trigger_config, channel_session_id, max_steps, token_budget, cost_budget_cents, history_message_window, history_token_window, handoff_keywords, handoff_tool_enabled, proposal_ai_draft_enabled, cases_enabled, split_messages, split_max_chars, followup, operator_enabled, operator_model, operator_tool_ids, status, published_at, superseded_at, created_at, created_by,pipeline_ids,knowledge_source_ids,provisioning_origin,inbound_debounce_ms";
const CREDENTIAL_COLUMNS =
"id, organization_id, provider, label, api_key_last4, validated_at, validation_error, models_available, is_active, created_by, created_at, updated_at";
+84
View File
@@ -42,6 +42,11 @@ interface FormState {
resposta_end_hour: string;
throttle_s: string;
jitter_s: string;
/** Atraso humano antes da 1ª bolha (0499). '' = usar o padrão. */
atraso_notar_ms: string;
ms_por_caractere: string;
atraso_minimo_ms: string;
atraso_maximo_ms: string;
daily_message_limit: string;
allow_sunday: boolean;
timezone: string;
@@ -59,6 +64,10 @@ function fromItem(item: PacingKnobsItem): FormState {
resposta_end_hour: o?.resposta_end_hour != null ? String(o.resposta_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) : "",
atraso_notar_ms: o?.atraso_notar_ms != null ? String(o.atraso_notar_ms) : "",
ms_por_caractere: o?.ms_por_caractere != null ? String(o.ms_por_caractere) : "",
atraso_minimo_ms: o?.atraso_minimo_ms != null ? String(o.atraso_minimo_ms) : "",
atraso_maximo_ms: o?.atraso_maximo_ms != null ? String(o.atraso_maximo_ms) : "",
daily_message_limit:
item.channel_session.daily_message_limit != null
? String(item.channel_session.daily_message_limit)
@@ -147,6 +156,10 @@ export function AntiBanSheet({ item, canWrite, onClose }: Props) {
resposta_end_hour: intOrNull(form.resposta_end_hour),
throttle_ms: msOrNull(form.throttle_s),
jitter_max_ms: msOrNull(form.jitter_s),
atraso_notar_ms: intOrNull(form.atraso_notar_ms),
ms_por_caractere: intOrNull(form.ms_por_caractere),
atraso_minimo_ms: intOrNull(form.atraso_minimo_ms),
atraso_maximo_ms: intOrNull(form.atraso_maximo_ms),
// `null` quando o Switch está no default: salvar esta ficha por outro
// motivo (aquecimento, throttle) não pode congelar o padrão do dia como
// escolha permanente — foi assim que uma instalação ficou muda todo
@@ -356,6 +369,77 @@ export function AntiBanSheet({ item, canWrite, onClose }: Props) {
</p>
</fieldset>
<fieldset className="flex flex-col gap-2">
<Label>{t("Atraso humano antes da primeira resposta (ms)")}</Label>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<label className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground">{t("Ver a notificação")}</span>
<Input
type="number"
min={0}
max={item.bounds.intervalMaxMs}
inputMode="numeric"
placeholder={String(eff.atrasoNotarMs)}
value={form.atraso_notar_ms}
onChange={(e) => set({ atraso_notar_ms: e.target.value })}
disabled={!canWrite}
aria-label={t("Tempo para ver a notificação em ms")}
className="w-24"
/>
</label>
<label className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground">{t("Por caractere")}</span>
<Input
type="number"
min={0}
max={item.bounds.msPorCaractereMax}
inputMode="numeric"
placeholder={String(eff.msPorCaractere)}
value={form.ms_por_caractere}
onChange={(e) => set({ ms_por_caractere: e.target.value })}
disabled={!canWrite}
aria-label={t("Milissegundos por caractere da resposta")}
className="w-24"
/>
</label>
<label className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground">{t("Mínimo")}</span>
<Input
type="number"
min={0}
max={item.bounds.intervalMaxMs}
inputMode="numeric"
placeholder={String(eff.atrasoMinimoMs)}
value={form.atraso_minimo_ms}
onChange={(e) => set({ atraso_minimo_ms: e.target.value })}
disabled={!canWrite}
aria-label={t("Atraso humano mínimo em ms")}
className="w-24"
/>
</label>
<label className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground">{t("Máximo")}</span>
<Input
type="number"
min={0}
max={item.bounds.atrasoMaximoMsMax}
inputMode="numeric"
placeholder={String(eff.atrasoMaximoMs)}
value={form.atraso_maximo_ms}
onChange={(e) => set({ atraso_maximo_ms: e.target.value })}
disabled={!canWrite}
aria-label={t("Atraso humano máximo em ms")}
className="w-24"
/>
</label>
</div>
<p className="text-xs text-muted-foreground">
{t(
"Quanto o agente \"pensa\" antes de mandar a primeira resposta (ver a notificação + digitação por caractere, limitado entre o mínimo e o máximo). Campo vazio usa o padrão — rápido demais parece robô; lento demais parece que caiu. Não mexe no intervalo entre mensagens.",
)}
</p>
</fieldset>
<fieldset className="flex flex-col gap-2">
<Label>{t("Teto diário de envios")}</Label>
<Input
+1
View File
@@ -32,6 +32,7 @@ export interface AgentVersionRow {
knowledge_source_ids: string[];
split_messages: boolean;
split_max_chars: number;
inbound_debounce_ms: number | null;
followup: {
enabled: boolean;
flow_pointer_ids: string[];
+2
View File
@@ -30,6 +30,8 @@ export interface PacingKnobsItem {
defaults: PacingKnobs;
bounds: {
intervalMaxMs: number;
msPorCaractereMax: number;
atrasoMaximoMsMax: number;
hourLastStart: number;
hourEnd: number;
daily_limit: { min: number; max: number };
+5
View File
@@ -36,6 +36,8 @@ export interface PublishedAgentConfig {
proposalAiDraftEnabled: boolean;
splitMessages: boolean;
splitMaxChars: number;
/** Janela de rajada inbound (ms) configurada na versão. `null` = usa a env. */
inboundDebounceMs: number | null;
/** input multimodal (imagem/áudio/pdf) habilitado no turno (Onda 3). */
multimodalInput: boolean;
/** tools open_human_case/provide_case_update habilitadas no turno (spec 15). */
@@ -112,6 +114,7 @@ interface Row {
proposal_ai_draft_enabled: boolean;
split_messages: boolean;
split_max_chars: number;
inbound_debounce_ms: number | null;
multimodal_input: boolean;
cases_enabled: boolean;
followup: unknown;
@@ -143,6 +146,7 @@ const SELECT_AGENT_CONFIG_COLUMNS = `a.operation_mode,a.paused_at,a.operation_re
v.proposal_ai_draft_enabled,
v.split_messages,
v.split_max_chars,
v.inbound_debounce_ms,
v.multimodal_input,
v.cases_enabled,
v.followup,
@@ -200,6 +204,7 @@ function mapAgentConfigRow(r: Row): PublishedAgentConfig {
proposalAiDraftEnabled: r.proposal_ai_draft_enabled,
splitMessages: r.split_messages,
splitMaxChars: r.split_max_chars,
inboundDebounceMs: r.inbound_debounce_ms ?? null,
multimodalInput: r.multimodal_input,
casesEnabled: r.cases_enabled,
followup: r.followup,
+34 -6
View File
@@ -49,7 +49,10 @@
*/
import type { Logger } from '../obs/logger';
/** Ver o cabeçalho: a parcela que não depende do tamanho do texto. */
/**
* Ver o cabeçalho: a parcela que não depende do tamanho do texto.
* É o DEFAULT — quem configura por conexão (channel_knobs 0499) recebe outro.
*/
export const ATRASO_NOTAR_MS = 900;
/** ≈45 caracteres/s — rápido de propósito; ver o cabeçalho. */
@@ -61,14 +64,34 @@ export const ATRASO_MINIMO_MS = 1200;
/** Acima disto o silêncio lê como queda, não como digitação. */
export const ATRASO_MAXIMO_MS = 7500;
/** Os quatro números do atraso humano — o default é o do código de antes. */
export interface AtrasoHumanoKnobs {
atrasoNotarMs?: number;
msPorCaractere?: number;
atrasoMinimoMs?: number;
atrasoMaximoMs?: number;
}
/** Os quatro números com o default do módulo preenchido (NULL/ausente = valor de antes). */
export function atrasoHumanoEfetivo(knobs: AtrasoHumanoKnobs | undefined): Required<AtrasoHumanoKnobs> {
return {
atrasoNotarMs: knobs?.atrasoNotarMs ?? ATRASO_NOTAR_MS,
msPorCaractere: knobs?.msPorCaractere ?? MS_POR_CARACTERE,
atrasoMinimoMs: knobs?.atrasoMinimoMs ?? ATRASO_MINIMO_MS,
atrasoMaximoMs: knobs?.atrasoMaximoMs ?? ATRASO_MAXIMO_MS,
};
}
/**
* Quanto esperar antes de mandar `texto`, em ms. Pura — é o que a torna
* testável sem relógio e sem canal.
* testável sem relógio e sem canal. `knobs` opcional: sem eles vale
* EXATAMENTE o comportamento histórico (regressão zero).
*/
export function calcularAtrasoHumano(texto: string): number {
export function calcularAtrasoHumano(texto: string, knobs?: AtrasoHumanoKnobs): number {
const ef = atrasoHumanoEfetivo(knobs);
const comprimento = (texto ?? '').trim().length;
const bruto = ATRASO_NOTAR_MS + MS_POR_CARACTERE * comprimento;
return Math.min(ATRASO_MAXIMO_MS, Math.max(ATRASO_MINIMO_MS, bruto));
const bruto = ef.atrasoNotarMs + ef.msPorCaractere * comprimento;
return Math.min(ef.atrasoMaximoMs, Math.max(ef.atrasoMinimoMs, bruto));
}
export interface EsperaHumanaArgs {
@@ -78,6 +101,11 @@ export interface EsperaHumanaArgs {
processamentoMs?: number;
sleep: (ms: number) => Promise<void>;
log: Logger;
/**
* Os quatro números do atraso por conexão (channel_knobs 0499). Omitir =
* comportamento histórico (defaults de atraso-humano.ts).
*/
knobs?: AtrasoHumanoKnobs;
/**
* Acende o "digitando…" no aparelho do cliente. OPCIONAL: canal que não sabe
* sinalizar presença simplesmente espera, e o ganho principal (não responder
@@ -101,7 +129,7 @@ export async function esperarComoHumano(args: EsperaHumanaArgs): Promise<number>
const gasto = args.processamentoMs ?? 0;
const ms = Math.max(
0,
calcularAtrasoHumano(args.texto) - (Number.isFinite(gasto) ? Math.max(0, gasto) : 0),
calcularAtrasoHumano(args.texto, args.knobs) - (Number.isFinite(gasto) ? Math.max(0, gasto) : 0),
);
// O cliente já esperou o alvo: não acrescentar presença decorativa nem sleep.
@@ -40,6 +40,7 @@ const AGENT: PublishedAgentConfig = {
proposalAiDraftEnabled: true,
splitMessages: false,
splitMaxChars: 400,
inboundDebounceMs: null,
multimodalInput: false,
casesEnabled: false,
toolIds: [],
+24 -1
View File
@@ -2620,6 +2620,19 @@ async function executarTurnoDoAgente(
// turno com dois vetos ficaria mudo por mais de 20 segundos: o conserto do
// "rápido demais" viraria o defeito simétrico, mais caro que o original.
let jaEsperouComoHumano = false;
// Knobs de atraso humano por conexão (0499) — lidos UMA vez por turno para a
// pausa antes da 1ª bolha (`esperarComoHumano`) e para o jitter entre bolhas
// (`throttle_ms + jitter_max_ms`). Sem linha em channel_knobs caem nos defaults
// de defaults.ts — que espelham os valores históricos (regressão zero).
const pacingDoTurno = !preview
? await loadChannelKnobs(pool, tenantId, input.channelSessionId, runLog)
: null;
const knobsDeAtrasoHumano = {
atrasoNotarMs: pacingDoTurno?.knobs.atrasoNotarMs,
msPorCaractere: pacingDoTurno?.knobs.msPorCaractere,
atrasoMinimoMs: pacingDoTurno?.knobs.atrasoMinimoMs,
atrasoMaximoMs: pacingDoTurno?.knobs.atrasoMaximoMs,
};
// Cap de envio (warm-up/diário) vetado neste turno — capturado aqui porque o veto
// não empurra outcome nenhum a `outcomes` (ver comentário no ponto de captura, mais
// abaixo). Diferente da janela horária (checada ANTES do modelo rodar, linha ~1233):
@@ -3169,6 +3182,9 @@ async function executarTurnoDoAgente(
agentConfig?.splitMaxChars ?? 600,
Math.max(1, maxSendsPerTurn - seq),
)[0] ?? body,
// Os quatro números do atraso por conexão (0499). Vazios (sem
// linha em channel_knobs / preview) = defaults históricos.
knobs: knobsDeAtrasoHumano,
// `processamentoMs` é a contribuição do #849 (@Teowfb): a pausa humana desconta o
// tempo que o turno JÁ gastou pensando, em vez de somar em cima dele. Sem este
// argumento o `gasto` de `atraso-humano.ts` cai no `?? 0` e o desconto não acontece —
@@ -3192,7 +3208,13 @@ async function executarTurnoDoAgente(
corposEnviados.push(finalBody);
const sleep =
deps.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
const jitter = () => 1200 + Math.floor(Math.random() * 800); // piso no throttle anti-ban (1.2s) — bolhas são mensagens físicas
const jitter = () =>
// Piso no throttle anti-ban do número (1.2s default) — bolhas são
// mensagens físicas. Lê os knobs da CONEXÃO (throttle_ms + jitter_max_ms,
// 0499) e não o literal: desde a 0010 os dois já são configuração por
// número, e o call site estava cravando `1200 + rand*800` ignorando-a.
(pacingDoTurno?.knobs.throttleMs ?? 1200) +
Math.floor(Math.random() * (pacingDoTurno?.knobs.jitterMaxMs ?? 800));
const enviar = (
corpo: string,
media?: FotoParaEnvio,
@@ -3441,6 +3463,7 @@ async function executarTurnoDoAgente(
tenantId,
leadId,
toStage: update.transition.to,
...(agentConfig !== null ? { pipelineIds: agentConfig.pipelineIds } : {}),
...(update.transition.reason !== undefined
? { reason: update.transition.reason }
: {}),
@@ -26,6 +26,7 @@ function fakeConfig(agentId: string): PublishedAgentConfig {
proposalAiDraftEnabled: true,
splitMessages: false,
splitMaxChars: 900,
inboundDebounceMs: null,
multimodalInput: false,
casesEnabled: false,
toolIds: [],
+27 -1
View File
@@ -1,7 +1,7 @@
import { expect, it, vi } from 'vitest';
import type pg from 'pg';
import { decidirRajada, janelaDeRajada } from './debounce';
import { decidirRajada, janelaDeRajada, debounceEfetivo, TETO_DEBOUNCE_MS } from './debounce';
const alvo = { organizationId: 'org1', contactId: 'contato1' };
/** Instante fixo: a janela é aritmética e o teste não depende do relógio. */
@@ -70,3 +70,29 @@ it('janelaDeRajada: sem debounce não há janela; com debounce a janela é agora
expect(janelaDeRajada(0, AGORA)).toBeUndefined();
expect(janelaDeRajada(750, AGORA)?.getTime()).toBe(AGORA + 750);
});
// ── Leitor de debounce configurável por agente (issue #1856) ────────────
const ENV = 8_000;
it('debounceEfetivo: campo vazio (null) usa o default da instalação — regressão zero', () => {
expect(debounceEfetivo(null, ENV)).toBe(ENV);
expect(debounceEfetivo(undefined, ENV)).toBe(ENV);
});
it('debounceEfetivo: campo preenchido vence o default', () => {
expect(debounceEfetivo(20_000, ENV)).toBe(20_000);
});
it('debounceEfetivo: 0 desliga a coalescência — não herda nem clampa', () => {
expect(debounceEfetivo(0, ENV)).toBe(0);
});
it('debounceEfetivo: teto de 60s — valor ACIMA do teto é clamado no limite', () => {
expect(TETO_DEBOUNCE_MS).toBe(60_000);
expect(debounceEfetivo(60_000, ENV)).toBe(60_000);
expect(debounceEfetivo(90_000, ENV)).toBe(60_000);
});
it('debounceEfetivo: valor NEGATIVO (dado sujo) clampa para 0, não quebra', () => {
expect(debounceEfetivo(-5, ENV)).toBe(0);
});
+23
View File
@@ -69,6 +69,29 @@ export function janelaDeRajada(debounceMs: number, agora: number = Date.now()):
return debounceMs > 0 ? new Date(agora + debounceMs) : undefined;
}
/**
* Teto da janela de rajada configurável por agente (#1856): não deixar ninguém
* travar o atendimento sem querer. Vale também como default de tela/UX.
*/
export const TETO_DEBOUNCE_MS = 60_000;
/**
* Janela de rajada EFETIVA de um agente.
*
* `null`/`undefined` (campo vazio na versão) = o `INBOUND_DEBOUNCE_MS` da
* instalação — regressão zero para quem nunca mexeu no campo. O valor
* configurado é CLAMPADO em [0, TETO_DEBOUNCE_MS]: 0 desliga a coalescência e
* valores acima do teto (dado sujo que entrou por fora da validação) caem para
* 60s em vez de travar o atendimento.
*/
export function debounceEfetivo(
configurado: number | null | undefined,
padraoInstalacao: number,
): number {
if (configurado === null || configurado === undefined) return padraoInstalacao;
return Math.min(Math.max(0, configurado), TETO_DEBOUNCE_MS);
}
/** Decide entre carona em job existente e janela nova. */
export async function decidirRajada(
pool: pg.Pool,
+42 -2
View File
@@ -15,10 +15,11 @@
import { z } from 'zod';
import type pg from 'pg';
import { loadConversationAgentConfig } from '../../agent/agent-config';
import { insertInboxItem } from '../../db/repository';
import type { Logger } from '../../obs/logger';
import { enqueueJob } from '../../queue/queue';
import { decidirRajada } from './debounce';
import { decidirRajada, debounceEfetivo } from './debounce';
import { avisoDeEventoMorto, IA_QUE_NAO_RESPONDEU } from '@/lib/event-log/aviso-de-evento-morto';
import { TIPOS_DERIVAVEIS, DERIVACAO_TERMINADA } from '@/lib/messaging/media/derivable';
import { decidirElegibilidadeDaConversa } from '@/lib/ai/elegibilidade/consulta-pg';
@@ -198,6 +199,45 @@ const TETO_ESPERA_DERIVACAO_MS = 120_000;
type DesfechoEvento = 'processado' | 'adiar';
/**
* Janela de rajada EFETIVA para o evento: a configurada na versão do agente
* desta conversa (#1856), com o `INBOUND_DEBOUNCE_MS` da instalação como
* default e clamp no teto de 60s (`debounceEfetivo`).
*
* A resolução é a de `loadConversationAgentConfig` — o `active_ai_agent_id` da
* conversa quando há dono explícito, senão o agente publicado da sessão. NÃO é
* a resolução completa do turno (`resolveTurnAgent`: router, classificador,
* campanha): numa conversa que o turno entregaria a outro agente sem torná-lo
* dono, vale a janela do agente da sessão (ou a env). Com o campo vazio
* (default de toda instalação) o valor vira o da env — regressão zero.
*
* Falha da consulta NÃO derruba o evento: degrada para a env, como a checagem
* de elegibilidade acima. A janela é afinação, não motivo para retry.
*/
async function debounceDoEvento(
pool: pg.Pool,
event: EventRow,
p: { conversation_id: string; channel_session_id: string },
padraoInstalacao: number,
log: Logger,
): Promise<number> {
try {
const agentConfig = await loadConversationAgentConfig(
pool,
event.organization_id,
p.conversation_id,
p.channel_session_id,
);
return debounceEfetivo(agentConfig?.inboundDebounceMs ?? null, padraoInstalacao);
} catch (err) {
log.warn('drain: janela de rajada do agente não resolveu — usando a da instalação', {
event_id: event.id,
error: (err instanceof Error ? err.message : String(err)).slice(0, 160),
});
return padraoInstalacao;
}
}
async function processEvent(
pool: pg.Pool,
event: EventRow,
@@ -493,7 +533,7 @@ async function processEvent(
const rajada = await decidirRajada(
pool,
{ organizationId: event.organization_id, contactId: p.contact_id },
knobs.debounceMs,
await debounceDoEvento(pool, event, p, knobs.debounceMs, log),
);
if (rajada.tipo === 'coalescido') {
log.info('drain: rajada coalescida em job pendente', {
@@ -24,6 +24,39 @@ describe('mirrorLeadStageToCrm', () => {
});
});
it('respeita os funis da versão publicada — sem funil autorizado o card não se move', async () => {
const sync = vi.fn().mockResolvedValue({
moveu: false, motivo: 'fora_do_escopo', detalhe: 'nenhum funil liberado para este assistente',
});
const r = await mirrorLeadStageToCrm(
db, cfg as never,
{ tenantId: 'org-1', leadId: 'contato-1', toStage: 'qualified', pipelineIds: [] },
{ sync },
);
expect(sync).toHaveBeenCalledWith(cfg.supabase, {
organizationId: 'org-1', contactId: 'contato-1', passo: 'qualified', escopoDeFunis: [],
});
expect(r).toMatchObject({ ok: false, reason: 'fora_do_escopo' });
});
it('leva ao sincronizador somente os funis que o agente publicado pode alterar', async () => {
const sync = vi.fn().mockResolvedValue({ moveu: true, motivo: 'movido' });
const r = await mirrorLeadStageToCrm(
db, cfg as never,
{ tenantId: 'org-1', leadId: 'contato-1', toStage: 'qualified', pipelineIds: ['funil-comercial'] },
{ sync },
);
expect(r).toEqual({ ok: true });
expect(sync).toHaveBeenCalledWith(cfg.supabase, {
organizationId: 'org-1', contactId: 'contato-1', passo: 'qualified',
escopoDeFunis: ['funil-comercial'],
});
});
it('estágio já ocupado é sucesso, não falha', async () => {
const sync = vi.fn().mockResolvedValue({ moveu: false, motivo: 'ja_esta_la' });
const r = await mirrorLeadStageToCrm(
+9 -1
View File
@@ -237,7 +237,14 @@ interface Deps {
export async function mirrorLeadStageToCrm(
_db: Queryable,
cfg: CrmEdgeConfig,
input: { tenantId: string; leadId: string; toStage: LeadStage; reason?: string },
input: {
tenantId: string;
leadId: string;
toStage: LeadStage;
reason?: string;
/** Funis autorizados na versão publicada; ausente só no caminho legado. */
pipelineIds?: readonly string[];
},
deps: Deps = {},
): Promise<MirrorResult> {
const sync = deps.sync ?? sincronizaEstagioDoAgente;
@@ -249,6 +256,7 @@ export async function mirrorLeadStageToCrm(
organizationId: input.tenantId,
contactId: input.leadId,
passo: input.toStage,
...(input.pipelineIds !== undefined ? { escopoDeFunis: input.pipelineIds } : {}),
});
if (r.moveu || r.motivo === 'ja_esta_la') return { ok: true };
+24
View File
@@ -21,6 +21,19 @@ 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;
/**
* Parcela FIXA do atraso humano antes da 1ª bolha (ms): ver a notificação,
* abrir a conversa, ler o que o cliente escreveu. Existe separada do termo
* proporcional porque mesmo um "Sim!" tem esse custo (atraso-humano.ts).
* NULL em channel_knobs = este default.
*/
atrasoNotarMs: number;
/** Taxa de digitação do atraso humano (ms por caractere) — default 22ms ≈ 45 c/s. NULL = default. */
msPorCaractere: number;
/** Piso do atraso humano (ms) — abaixo dele o atraso não significa nada. NULL = default. */
atrasoMinimoMs: number;
/** Teto do atraso humano (ms) — acima dele o silêncio lê como queda. NULL = default. */
atrasoMaximoMs: number;
/**
* 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 retomada de conversa
@@ -74,6 +87,10 @@ export interface PacingKnobs {
export const KNOB_BOUNDS = {
/** teto de intervalo/jitter aceito na UI (ms). */
intervalMaxMs: 600_000,
/** teto de ms_por_caractere aceito na UI (ms) — 202ms ≈ 5 c/s é absurdamente lento; nada real precisa de mais. */
msPorCaractereMax: 200,
/** teto do atraso máximo aceito na UI (ms) — o piso é o default do anti-ban (1200). */
atrasoMaximoMsMax: 60_000,
/** maior hora aceita como INÍCIO de janela (fim vai até 24). */
hourLastStart: 23,
/** fim de janela é exclusivo e pode chegar à meia-noite seguinte. */
@@ -83,6 +100,13 @@ export const KNOB_BOUNDS = {
export const PACING_DEFAULTS: PacingKnobs = {
throttleMs: 1200, // 1 msg / 1,2s
jitterMaxMs: 800,
// Números do atraso humano antes da 1ª bolha (atraso-humano.ts). Espelham os
// valores que foram SEMPRE os literais do módulo — regressão zero para quem
// nunca configurou. Coluna em channel_knobs NULL cai aqui.
atrasoNotarMs: 900,
msPorCaractere: 22,
atrasoMinimoMs: 1200,
atrasoMaximoMs: 7500,
windowStartHour: 7, // janela 7h-22h
windowEndHour: 22,
// Espelha a janela de disparo: quem nunca gravou as colunas `resposta_*`
+10
View File
@@ -20,6 +20,11 @@ interface ChannelKnobsRow {
/** Janela da RESPOSTA do agente (0495). NULL = usa `window_*` (comportamento anterior). */
resposta_start_hour: number | null;
resposta_end_hour: number | null;
/** Números do atraso humano antes da 1ª bolha (0499). NULL = default (defaults.ts). */
atraso_notar_ms: number | null;
ms_por_caractere: number | null;
atraso_minimo_ms: number | null;
atraso_maximo_ms: number | null;
allow_sunday: boolean | null;
timezone: string | null;
warmup_daily_caps: unknown; // jsonb — shape validado em parseWarmupCaps (nunca confiado)
@@ -96,6 +101,7 @@ export async function loadChannelKnobs(
const { rows } = await db.query<ChannelKnobsRow>(
`select k.throttle_ms, k.jitter_max_ms, k.window_start_hour, k.window_end_hour,
k.resposta_start_hour, k.resposta_end_hour,
k.atraso_notar_ms, k.ms_por_caractere, k.atraso_minimo_ms, k.atraso_maximo_ms,
k.allow_sunday, k.timezone, k.warmup_daily_caps, k.number_activated_at,
o.timezone as org_timezone
from organizations o
@@ -126,6 +132,10 @@ export async function loadChannelKnobs(
knobs: {
throttleMs: row.throttle_ms ?? PACING_DEFAULTS.throttleMs,
jitterMaxMs: row.jitter_max_ms ?? PACING_DEFAULTS.jitterMaxMs,
atrasoNotarMs: row.atraso_notar_ms ?? PACING_DEFAULTS.atrasoNotarMs,
msPorCaractere: row.ms_por_caractere ?? PACING_DEFAULTS.msPorCaractere,
atrasoMinimoMs: row.atraso_minimo_ms ?? PACING_DEFAULTS.atrasoMinimoMs,
atrasoMaximoMs: row.atraso_maximo_ms ?? PACING_DEFAULTS.atrasoMaximoMs,
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
+2 -1
View File
@@ -24,7 +24,7 @@ export const DUPLICATE_AGENT_COLUMNS =
* basta, se o INSERT não a escreve a cópia nasce com o default do banco.
*/
export const DUPLICATE_VERSION_COLUMNS =
"id, organization_id, agent_id, version_number, system_prompt, provider, model, credential_id, tool_ids, trigger_config, channel_session_id, max_steps, token_budget, cost_budget_cents, history_message_window, history_token_window, handoff_keywords, handoff_tool_enabled, proposal_ai_draft_enabled, cases_enabled, split_messages, split_max_chars, followup, operator_enabled, operator_model, operator_tool_ids, status, published_at, superseded_at, created_at, created_by,pipeline_ids,knowledge_source_ids,provisioning_origin";
"id, organization_id, agent_id, version_number, system_prompt, provider, model, credential_id, tool_ids, trigger_config, channel_session_id, max_steps, token_budget, cost_budget_cents, history_message_window, history_token_window, handoff_keywords, handoff_tool_enabled, proposal_ai_draft_enabled, cases_enabled, split_messages, split_max_chars, followup, operator_enabled, operator_model, operator_tool_ids, status, published_at, superseded_at, created_at, created_by,pipeline_ids,knowledge_source_ids,provisioning_origin,inbound_debounce_ms";
export type DuplicateAgentError =
| "not_found"
@@ -78,6 +78,7 @@ function versionPayloadFrom(src: Record<string, unknown>) {
operator_tool_ids: src.operator_tool_ids,
split_messages: src.split_messages,
split_max_chars: src.split_max_chars,
inbound_debounce_ms: src.inbound_debounce_ms ?? null,
followup: src.followup,
// ESCOPO. As duas faltavam — `pipeline_ids` desde a 0125, e o cabeçalho
// deste arquivo já mandava ("coluna nova entra aqui E em
+9
View File
@@ -169,6 +169,15 @@ const versionShapeSchema = z
// pelo pacing anti-ban. Defaults espelham a migration 0059.
split_messages: z.boolean().default(false),
split_max_chars: z.number().int().min(80).max(4000).default(600),
/**
* Janela de coalescência de rajada inbound para ESTE agente (ms).
*
* `null`/ausente = usa o `INBOUND_DEBOUNCE_MS` da instalação (comportamento
* de sempre — regressão zero). 0 desliga a coalescência de rajada para o
* agente (job imediato). 1..60000 define a janela, com TETO de 60s para
* ninguém travar o atendimento sem querer (#1856).
*/
inbound_debounce_ms: z.number().int().min(0).max(60000).nullable().optional(),
followup: followupConfigSchema,
// ── Papel OPERADOR (spec 16 §3.2) ───────────────────────────────────────
// Todos com `.default(...)`, e é o que mantém retrocompatível: agent e
+14
View File
@@ -111,6 +111,11 @@ export const pacingKnobsUpdateSchema = z
channel_session_id: z.string().uuid(),
throttle_ms: z.number().int().min(0).max(KNOB_BOUNDS.intervalMaxMs).nullable().optional(),
jitter_max_ms: z.number().int().min(0).max(KNOB_BOUNDS.intervalMaxMs).nullable().optional(),
// Atraso humano antes da 1ª bolha (0499). null = voltar ao default (defaults.ts).
atraso_notar_ms: z.number().int().min(0).max(KNOB_BOUNDS.intervalMaxMs).nullable().optional(),
ms_por_caractere: z.number().int().min(0).max(KNOB_BOUNDS.msPorCaractereMax).nullable().optional(),
atraso_minimo_ms: z.number().int().min(0).max(KNOB_BOUNDS.intervalMaxMs).nullable().optional(),
atraso_maximo_ms: z.number().int().min(0).max(KNOB_BOUNDS.atrasoMaximoMsMax).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(),
/**
@@ -174,6 +179,11 @@ export interface ChannelKnobsRow {
/** Janela da RESPOSTA (0495). Ausente/null = herda a janela de disparo. */
resposta_start_hour?: number | null;
resposta_end_hour?: number | null;
/** Atraso humano antes da 1ª bolha (0499). Ausente/null = default. */
atraso_notar_ms?: number | null;
ms_por_caractere?: number | null;
atraso_minimo_ms?: number | null;
atraso_maximo_ms?: number | null;
allow_sunday: boolean | null;
timezone: string | null;
warmup_daily_caps: unknown;
@@ -200,6 +210,10 @@ export function effectiveKnobs(row: ChannelKnobsRow | null, fusoDaOrg?: string |
return {
throttleMs: row?.throttle_ms ?? PACING_DEFAULTS.throttleMs,
jitterMaxMs: row?.jitter_max_ms ?? PACING_DEFAULTS.jitterMaxMs,
atrasoNotarMs: row?.atraso_notar_ms ?? PACING_DEFAULTS.atrasoNotarMs,
msPorCaractere: row?.ms_por_caractere ?? PACING_DEFAULTS.msPorCaractere,
atrasoMinimoMs: row?.atraso_minimo_ms ?? PACING_DEFAULTS.atrasoMinimoMs,
atrasoMaximoMs: row?.atraso_maximo_ms ?? PACING_DEFAULTS.atrasoMaximoMs,
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.
+6
View File
@@ -89,6 +89,12 @@ export async function knobsDoCanal(
return {
throttleMs: linha.throttle_ms ?? PACING_DEFAULTS.throttleMs,
jitterMaxMs: linha.jitter_max_ms ?? PACING_DEFAULTS.jitterMaxMs,
// Automação é disparo em massa — o atraso humano (0499) não se aplica, mas
// o tipo PacingKnobs pede os quatro; herdam o default conservador.
atrasoNotarMs: PACING_DEFAULTS.atrasoNotarMs,
msPorCaractere: PACING_DEFAULTS.msPorCaractere,
atrasoMinimoMs: PACING_DEFAULTS.atrasoMinimoMs,
atrasoMaximoMs: PACING_DEFAULTS.atrasoMaximoMs,
windowStartHour: linha.window_start_hour ?? PACING_DEFAULTS.windowStartHour,
windowEndHour: linha.window_end_hour ?? PACING_DEFAULTS.windowEndHour,
// Automação é disparo: nunca lê a janela de resposta (0495). O par espelha o
+15
View File
@@ -1820,6 +1820,7 @@ export type Database = {
history_message_window: number
history_token_window: number
id: string
inbound_debounce_ms: number | null
knowledge_source_ids: string[]
max_steps: number
model: string
@@ -1858,6 +1859,7 @@ export type Database = {
history_message_window?: number
history_token_window?: number
id?: string
inbound_debounce_ms?: number | null
knowledge_source_ids?: string[]
max_steps?: number
model: string
@@ -1896,6 +1898,7 @@ export type Database = {
history_message_window?: number
history_token_window?: number
id?: string
inbound_debounce_ms?: number | null
knowledge_source_ids?: string[]
max_steps?: number
model?: string
@@ -3951,6 +3954,10 @@ export type Database = {
warmup_daily_caps: Json | null
window_end_hour: number | null
window_start_hour: number | null
atraso_notar_ms: number | null
ms_por_caractere: number | null
atraso_minimo_ms: number | null
atraso_maximo_ms: number | null
}
Insert: {
allow_sunday?: boolean | null
@@ -3967,6 +3974,10 @@ export type Database = {
warmup_daily_caps?: Json | null
window_end_hour?: number | null
window_start_hour?: number | null
atraso_notar_ms?: number | null
ms_por_caractere?: number | null
atraso_minimo_ms?: number | null
atraso_maximo_ms?: number | null
}
Update: {
allow_sunday?: boolean | null
@@ -3983,6 +3994,10 @@ export type Database = {
warmup_daily_caps?: Json | null
window_end_hour?: number | null
window_start_hour?: number | null
atraso_notar_ms?: number | null
ms_por_caractere?: number | null
atraso_minimo_ms?: number | null
atraso_maximo_ms?: number | null
}
Relationships: [
{
+19
View File
@@ -1723,6 +1723,11 @@ export const DICIONARIO: Traducoes = {
"Volume de texto por atendimento": { es: "Volumen de texto por atención" },
"Custo máximo por atendimento (centavos)": { es: "Costo máximo por atención (centavos)" },
"Mensagens anteriores que ele lê": { es: "Mensajes anteriores que lee" },
"Esperar antes de responder (segundos)": { es: "Esperar antes de responder (segundos)" },
"Mensagens do mesmo contato dentro desse tempo viram uma resposta só. Vazio usa a janela padrão da instalação (máximo 60 segundos).": {
es: "Los mensajes del mismo contacto dentro de este tiempo se convierten en una sola respuesta. Vacío usa la ventana predeterminada de la instalación (máximo 60 segundos).",
},
"Vazio = padrão da instalação": { es: "Vacío = predeterminado de la instalación" },
"Tamanho máximo desse histórico": { es: "Tamaño máximo de ese historial" },
"As instruções dele": { es: "Sus instrucciones" },
"Estilo de resposta": { es: "Estilo de respuesta" },
@@ -5260,6 +5265,20 @@ export const DICIONARIO: Traducoes = {
"Intervalo mínimo entre mensagens do mesmo número, mais uma variação aleatória — ritmo cravado parece robô para o WhatsApp.": {
es: "Intervalo mínimo entre mensajes del mismo número, más una variación aleatoria. Un ritmo siempre igual hace que WhatsApp lo detecte como un robot.",
},
"Atraso humano antes da primeira resposta (ms)": {
es: "Retraso humano antes de la primera respuesta (ms)",
},
"Ver a notificação": { es: "Ver la notificación" },
"Mínimo": { es: "Mínimo" },
"Máximo": { es: "Máximo" },
"Tempo para ver a notificação em ms": { es: "Tiempo para ver la notificación en ms" },
"Por caractere": { es: "Por carácter" },
"Milissegundos por caractere da resposta": { es: "Milisegundos por carácter de la respuesta" },
"Atraso humano mínimo em ms": { es: "Retraso humano mínimo en ms" },
"Atraso humano máximo em ms": { es: "Retraso humano máximo en ms" },
"Quanto o agente \"pensa\" antes de mandar a primeira resposta (ver a notificação + digitação por caractere, limitado entre o mínimo e o máximo). Campo vazio usa o padrão — rápido demais parece robô; lento demais parece que caiu. Não mexe no intervalo entre mensagens.": {
es: "Cuánto \"piensa\" el agente antes de enviar la primera respuesta (ver la notificación + escritura por carácter, limitado entre un mínimo y un máximo). Campo vacío usa el estándar: demasiado rápido parece robot; demasiado lento parece que se cayó. No cambia el intervalo entre mensajes.",
},
"Teto diário de envios": { es: "Tope diario de envíos" },
"sem teto definido": { es: "sin tope definido" },
"Teto diário de mensagens": { es: "Tope diario de mensajes" },
+72 -3
View File
@@ -91,6 +91,7 @@ export function tituloRedigido(titulo: string | null): string {
export interface Filtravel<T> extends PromiseLike<T> {
eq(coluna: string, valor: string | boolean): Filtravel<T>;
in(coluna: string, valores: string[]): Filtravel<T>;
not(coluna: string, operador: "is", valor: null): Filtravel<T>;
limit(n: number): Filtravel<T>;
}
@@ -104,6 +105,15 @@ export interface ClienteDaCascata {
/** O texto-sentinela que marca uma nota de memória do agente JÁ redigida. */
export const NOTA_REDIGIDA = "(anonimizado)";
/**
* O body que a anonimização do BANCO grava em toda mensagem redigida
* (`fn_redigir_conversas_ao_anonimizar` e `fn_lgpd_cascade_redact_contact`).
* Só ela o escreve — por isso ele é o marcador de "esta mensagem já passou pela
* anonimização", e poupa quem voltou a escrever depois (mensagem nova tem body
* de verdade).
*/
export const MENSAGEM_REDIGIDA = "[mensagem anonimizada]";
/**
* Redação do `tool_calls` de uma run (issue #1957). Cada passo vira
* `{ step?, tool_name?, redacted: true, tool_calls: [{ tool_name }] }`: fica
@@ -154,6 +164,8 @@ export interface ResultadoDaRedacao {
estadosRedigidos: number;
/** Se o social_identity do contato foi removido AGORA (#1957). */
socialIdentidadeRedigida: boolean;
/** Quantas mensagens já anonimizadas tiveram a transcrição da mídia apagada AGORA (0497). */
transcricoesRedigidas: number;
/**
* As tabelas que esta execução REALMENTE tocou.
*
@@ -177,7 +189,8 @@ export function houveRedacao(r: ResultadoDaRedacao): boolean {
r.memoriasRedigidas > 0 ||
r.runsRedigidas > 0 ||
r.estadosRedigidos > 0 ||
r.socialIdentidadeRedigida
r.socialIdentidadeRedigida ||
r.transcricoesRedigidas > 0
);
}
@@ -437,6 +450,44 @@ export async function completarRedacaoDoContato(
}
}
// ── Passo 9 — TRANSCRIÇÃO DA MÍDIA (`messages.media_derived_text`) — 0497 ──
//
// Achado na triagem do #1988. O banco redige o body da mensagem na virada de
// is_anonymized, e até a 0497 deixava a transcrição do áudio (e o OCR da
// imagem) legível. A migration fecha o gatilho e cura o passado; este passo
// fecha a janela que nenhum gatilho alcança: o `media-derive-worker` que leu
// a mídia ANTES da anonimização e grava o texto DEPOIS dela.
//
// Só mensagem com body `MENSAGEM_REDIGIDA`: é a que o banco já anonimizou.
// Mensagem com body de verdade é de quem voltou a escrever, e fica. O UPDATE
// repete o predicado em vez de uma lista de ids — um contato pode ter
// centenas de áudios, e `.in()` vira URL (ver `CONTATOS_POR_BLOCO`).
let transcricoesRedigidas = 0;
const { data: transcData, error: transcSelErr } = await db
.from("messages")
.select("id")
.eq("organization_id", contato.organizationId)
.eq("contact_id", contato.id)
.eq("body", MENSAGEM_REDIGIDA)
.not("media_derived_text", "is", null);
if (transcSelErr) falhas.push(`messages media_derived_text select: ${transcSelErr.message}`);
const comTranscricao = ((transcData ?? []) as { id: string }[]).length;
if (comTranscricao > 0) {
const { error } = await db
.from("messages")
.update({ media_derived_text: null })
.eq("organization_id", contato.organizationId)
.eq("contact_id", contato.id)
.eq("body", MENSAGEM_REDIGIDA)
.not("media_derived_text", "is", null);
if (error) falhas.push(`messages media_derived_text: ${error.message}`);
else {
transcricoesRedigidas = comTranscricao;
tabelas.push("messages:media_derived_text");
}
}
return {
leadsRedigidas,
atividadesRedigidas,
@@ -444,6 +495,7 @@ export async function completarRedacaoDoContato(
runsRedigidas,
estadosRedigidos,
socialIdentidadeRedigida,
transcricoesRedigidas,
tabelas,
falhas,
};
@@ -495,7 +547,7 @@ export interface ResultadoDaVarredura {
falhas: string[];
}
/** Um contato tem resíduo se alguma lead, atividade, memória, run, estado ou identidade social dele ainda não foi redigida. */
/** Um contato tem resíduo se alguma lead, atividade, memória, run, estado, identidade social ou transcrição dele ainda não foi redigida. */
function idsComResiduo(argumentos: {
leads: { contact_id: string | null; title: string | null }[];
atividades: { contact_id: string | null; payload: unknown }[];
@@ -503,8 +555,10 @@ function idsComResiduo(argumentos: {
runs: { contact_id: string | null; tool_calls: unknown }[];
estados: { contact_id: string | null; next_action: string | null; qualification: unknown }[];
sociais: { id: string; social_identity: unknown }[];
/** Já filtradas no banco: mensagem anonimizada que ainda guarda transcrição. */
transcricoes: { contact_id: string | null }[];
}): Set<string> {
const { leads, atividades, notas, runs, estados, sociais } = argumentos;
const { leads, atividades, notas, runs, estados, sociais, transcricoes } = argumentos;
const comResiduo = new Set<string>();
for (const l of leads) {
if (l.contact_id && !jaRedigida(l.title)) comResiduo.add(l.contact_id);
@@ -530,6 +584,9 @@ function idsComResiduo(argumentos: {
for (const s of sociais) {
if (s.social_identity !== null && s.social_identity !== undefined) comResiduo.add(s.id);
}
for (const m of transcricoes) {
if (m.contact_id) comResiduo.add(m.contact_id);
}
return comResiduo;
}
@@ -619,6 +676,17 @@ export async function varrerRedacoesIncompletas(
.in("id", bloco);
if (socialErr) falhas.push(`contacts social_identity varredura: ${socialErr.message}`);
// Filtrado no BANCO, ao contrário das consultas acima: um contato tem
// milhares de mensagens, e trazer todas para decidir em memória custaria a
// rodada saudável inteira. Em regime a resposta é vazia.
const { data: transcricoes, error: transcErr } = await db
.from("messages")
.select("contact_id")
.in("contact_id", bloco)
.eq("body", MENSAGEM_REDIGIDA)
.not("media_derived_text", "is", null);
if (transcErr) falhas.push(`messages media_derived_text varredura: ${transcErr.message}`);
const achados = idsComResiduo({
leads: (leads ?? []) as { contact_id: string | null; title: string | null }[],
atividades: (atvs ?? []) as { contact_id: string | null; payload: unknown }[],
@@ -630,6 +698,7 @@ export async function varrerRedacoesIncompletas(
qualification: unknown;
}[],
sociais: (sociais ?? []) as { id: string; social_identity: unknown }[],
transcricoes: (transcricoes ?? []) as { contact_id: string | null }[],
});
// A detecção não filtra org (ver o cabeçalho): um `contact_id` que não
// saiu da lista de contatos anonimizados não vira visita.
+9 -3
View File
@@ -180,15 +180,21 @@ interface SetupMetadata {
proposal_hash?: string;
prepared_only?: boolean;
}
// Colunas em que `null` na linha do banco é o mesmo que ausente na versão montada: o hash
// gravado na criação parte do objeto parseado (sem a chave), e a conferência parte da
// linha lida (com `null`). Sem esta lista os dois hashes divergem e o setup recusa com
// "O rascunho foi alterado" — o que a 0498 (inbound_debounce_ms) fez nas 5 specs de
// tests/invariants/prospecting-agent-setup.test.ts.
// ponytail: lista explícita; coluna nullable nova em ai_agent_versions entra aqui (#2004).
const NULO_E_AUSENTE = new Set(["trigger_config", "inbound_debounce_ms"]);
function versionHash(row: Record<string, unknown>) {
const keys = Object.keys(versionCreateSchema.shape);
return hash(
versionCreateSchema.parse(
Object.fromEntries(
keys
.filter(
(key) => row[key] !== undefined && !(key === "trigger_config" && row[key] === null),
)
.filter((key) => row[key] !== undefined && !(NULO_E_AUSENTE.has(key) && row[key] === null))
.map((key) => [key, row[key]]),
),
),
+662
View File
@@ -43588,6 +43588,612 @@ $t$;
revoke execute on function public.fn_lgpd_redigir_tool_calls(jsonb) from public, anon, authenticated;
notify pgrst, 'reload schema';
-- ---- anonimizar apaga também a transcrição da mídia (migration 0497) ----
-- Achado na triagem do #1988 (@AlecLimaDev). `messages.media_derived_text`
-- (transcrição/OCR do media-derive-worker, 0058) sobrevivia a toda anonimização.
-- Uma linha (`media_derived_text = null`) em cada UPDATE de messages dos dois
-- corpos, que partem do corpo ATUAL: o gatilho da 0494 (bloco logo acima) e a
-- cascata da 0483. Corpo IDÊNTICO ao da migration 0497 (gate
-- `apendice-do-baseline-nao-diverge-da-cadeia`). Entra ANTES do bloco da
-- VARREDURA anon (0116), que proíbe `create function` depois dela. A cura no fim
-- é idempotente pelo `is not null` — o update.sh reaplica sem reescrever.
create or replace function public.fn_redigir_conversas_ao_anonimizar()
returns trigger
language plpgsql
security definer
set search_path = public, pg_temp
as $f$
declare
v_variantes text[];
v_lid text;
v_msgs_de_grupo uuid[];
begin
insert into public.storage_redaction_queue (organization_id, bucket, object_path)
select distinct new.organization_id, 'whatsapp-media', m.media_storage_path
from public.messages m
where m.organization_id = new.organization_id
and m.conversation_id in (
select c.id from public.conversations c
where c.contact_id = new.id and c.organization_id = new.organization_id)
and m.media_storage_path is not null
and length(m.media_storage_path) > 0
on conflict (bucket, object_path) do nothing;
update public.messages set
body = '[mensagem anonimizada]',
media_url = null,
media_mime = null,
media_size_bytes = null,
media_storage_path = null,
media_derived_text = null,
metadata = '{}'::jsonb,
updated_at = now()
where organization_id = new.organization_id
and conversation_id in (
select c.id from public.conversations c
where c.contact_id = new.id and c.organization_id = new.organization_id);
update public.conversations set
metadata = '{}'::jsonb,
last_message_preview = null,
last_handoff_reason = null,
updated_at = now()
where contact_id = new.id and organization_id = new.organization_id;
update public.lead_checkpoints set
rolling_summary = '[resumo anonimizado]',
commitments = '[]'::jsonb,
objections = '[]'::jsonb,
next_action = null,
declaracao = null
where contact_id = new.id and organization_id = new.organization_id;
-- ── issue #1964 — lead_notes: a memória do agente sobre o contato ─────────
update public.lead_notes set
headline = '(anonimizado)',
body = '(anonimizado)',
embedding = null,
updated_at = now()
where organization_id = new.organization_id
and contact_id = new.id
and (headline is distinct from '(anonimizado)'
or body is distinct from '(anonimizado)'
or embedding is not null);
-- ── issue #1964 — ai_agent_runs.tool_calls: preserva o nome e redige o resto
-- A transformação espelha `redigirToolCalls` (lib/lgpd/cascata.ts): cada
-- passo vira `{ step?, tool_name?, redacted: true, tool_calls: [{ tool_name }] }`.
-- Fica QUAIS ferramentas rodaram e em que passo; sai o texto do modelo, os
-- argumentos e os resultados. Guard no WHERE: só corre quando há passo sem
-- `redacted`, então o `[]` de nascença e a run já redigida não são tocados.
update public.ai_agent_runs set
tool_calls = public.fn_lgpd_redigir_tool_calls(ai_agent_runs.tool_calls)
where ai_agent_runs.organization_id = new.organization_id
and ai_agent_runs.contact_id = new.id
and exists (
select 1 from jsonb_array_elements(ai_agent_runs.tool_calls) s
where coalesce(s->>'redacted', 'false')::boolean is not true
);
-- ── issue #1964 — lead_state: a próxima ação e a qualificação da lead ─────
update public.lead_state set
next_action = null,
qualification = '{}'::jsonb,
updated_at = now()
where organization_id = new.organization_id
and contact_id = new.id
and (next_action is not null
or coalesce(qualification, '{}'::jsonb) <> '{}'::jsonb);
-- ── issue #1964 — contacts.social_identity ───────────────────────────────
-- O índice único parcial `where social_identity is not null` torna anular
-- seguro (a linha sai do índice sem violar unicidade). Guard no WHERE para a
-- varredura não reescrever o que já está anonimizado.
update public.contacts set
social_identity = null,
updated_at = now()
where id = new.id
and organization_id = new.organization_id
and social_identity is not null;
-- Mensagens de grupo escritas pelo titular (0482, ver o cabeçalho daquele bloco).
v_variantes := coalesce(public.fn_telefone_variantes(coalesce(old.phone_number, new.phone_number)), '{}');
v_lid := coalesce(old.wa_lid, new.wa_lid);
select coalesce(array_agg(m.id), '{}')
into v_msgs_de_grupo
from public.messages m
where m.organization_id = new.organization_id
and m.metadata ? 'group_sender'
and (
regexp_replace(coalesce(m.metadata->'group_sender'->>'phone', ''), '\D', '', 'g') = any(v_variantes)
or (v_lid is not null and m.metadata->'group_sender'->>'lid' = v_lid)
);
if cardinality(v_msgs_de_grupo) > 0 then
insert into public.storage_redaction_queue (organization_id, bucket, object_path)
select distinct new.organization_id, 'whatsapp-media', m.media_storage_path
from public.messages m
where m.organization_id = new.organization_id
and m.id = any(v_msgs_de_grupo)
and m.media_storage_path is not null
and length(m.media_storage_path) > 0
on conflict (bucket, object_path) do nothing;
update public.conversations set
last_message_preview = null,
updated_at = now()
where organization_id = new.organization_id
and id in (select m.conversation_id from public.messages m
where m.organization_id = new.organization_id and m.id = any(v_msgs_de_grupo));
update public.messages set
body = '[mensagem anonimizada]',
media_url = null,
media_mime = null,
media_size_bytes = null,
media_storage_path = null,
media_derived_text = null,
metadata = '{}'::jsonb,
updated_at = now()
where organization_id = new.organization_id
and id = any(v_msgs_de_grupo);
end if;
return new;
end
$f$;
-- As DUAS origens de EXECUTE (item 9 do CLAUDE.md): o grant a PUBLIC da criação
-- e o grant nominal a anon do ALTER DEFAULT PRIVILEGES do baseline.
revoke all on function public.fn_redigir_conversas_ao_anonimizar() from public;
revoke execute on function public.fn_redigir_conversas_ao_anonimizar() from anon;
revoke execute on function public.fn_redigir_conversas_ao_anonimizar() from authenticated;
create or replace function public.fn_lgpd_cascade_redact_contact(p_organization_id uuid, p_contact_id uuid, p_request_id uuid) returns jsonb
language plpgsql security definer
set search_path to 'public', 'extensions', 'pg_temp'
as $$
declare
v_already bool;
v_counts jsonb := '{}'::jsonb;
v_media_paths text[] := '{}';
v_anon_label text;
v_count int;
v_variantes text[] := '{}';
begin
perform public.fn_service_lock(p_organization_id,p_contact_id);
select is_anonymized into v_already
from contacts
where id = p_contact_id and organization_id = p_organization_id;
if not found then
raise exception 'contact not found' using errcode = 'P0002';
end if;
if v_already then
return jsonb_build_object('already_anonymized', true, 'counts', v_counts, 'media_paths', v_media_paths);
end if;
v_anon_label := 'Cliente Anonimizado #' || substring(p_contact_id::text from 1 for 8);
select coalesce(public.fn_telefone_variantes(phone_number), '{}')
into v_variantes
from contacts
where id = p_contact_id and organization_id = p_organization_id;
select coalesce(array_agg(distinct media_storage_path) filter (where media_storage_path is not null), '{}')
into v_media_paths
from messages
where organization_id = p_organization_id
and conversation_id in (
select id from conversations
where contact_id = p_contact_id and organization_id = p_organization_id
);
-- 1. contacts (irreversible)
update contacts set
name = v_anon_label,
display_name = v_anon_label,
email = null,
phone_number = null,
cpf_encrypted = null,
cpf_hash = null,
birthdate = null,
is_anonymized = true,
anonymized_at = now(),
consent = '{}'::jsonb,
source_metadata = '{}'::jsonb,
tags = '{}'::text[],
updated_at = now()
where id = p_contact_id and organization_id = p_organization_id;
get diagnostics v_count = row_count;
v_counts := v_counts || jsonb_build_object('contacts', v_count);
-- 2. conversations metadata + preview strip
update conversations set
metadata = '{}'::jsonb,
last_message_preview = null,
last_handoff_reason = null,
updated_at = now()
where contact_id = p_contact_id and organization_id = p_organization_id;
get diagnostics v_count = row_count;
v_counts := v_counts || jsonb_build_object('conversations', v_count);
-- 3. messages: redact body + null media + strip metadata (preserve status/timestamps/conversation_id)
update messages set
body = '[mensagem anonimizada]',
media_url = null,
media_mime = null,
media_size_bytes = null,
media_storage_path = null,
media_derived_text = null,
metadata = '{}'::jsonb,
updated_at = now()
where organization_id = p_organization_id
and conversation_id in (
select id from conversations
where contact_id = p_contact_id and organization_id = p_organization_id
);
get diagnostics v_count = row_count;
v_counts := v_counts || jsonb_build_object('messages', v_count);
-- 4. crm_lead_activities — strip payload, metadata E reason (migration 0071).
update crm_lead_activities set
payload = '{}'::jsonb,
metadata = '{}'::jsonb,
reason = null
where organization_id = p_organization_id
and (
contact_id = p_contact_id
or lead_id in (
select lead_id from crm_lead_links
where target_kind = 'contact'
and target_id = p_contact_id
and organization_id = p_organization_id
)
or lead_id in (
select id from crm_leads
where contact_id = p_contact_id and organization_id = p_organization_id
)
);
get diagnostics v_count = row_count;
v_counts := v_counts || jsonb_build_object('activities', v_count);
-- 5. crm_leads — strip title/description/custom_fields/source_metadata/tags but PRESERVE pipeline/stage/value
update crm_leads set
title = v_anon_label,
description = null,
custom_fields = '{}'::jsonb,
source_metadata = '{}'::jsonb,
tags = '{}'::text[],
updated_at = now()
where organization_id = p_organization_id
and (
contact_id = p_contact_id
or id in (
select lead_id from crm_lead_links
where target_kind = 'contact'
and target_id = p_contact_id
and organization_id = p_organization_id
)
);
get diagnostics v_count = row_count;
v_counts := v_counts || jsonb_build_object('leads', v_count);
-- 6. orders — PRESERVE values + status + timestamps. Strip personal fields from payload jsonb
-- and replace customer_external_id with null (FK-safe; soft de-link). Keep contact_id null.
update orders set
payload = (coalesce(payload, '{}'::jsonb))
- 'customer'
- 'customer_name'
- 'customer_email'
- 'customer_phone'
- 'shipping_address'
- 'billing_address'
- 'contact_identification',
customer_external_id = null,
contact_id = null,
is_anonymized = true,
updated_at = now()
where organization_id = p_organization_id
and contact_id = p_contact_id;
get diagnostics v_count = row_count;
v_counts := v_counts || jsonb_build_object('orders', v_count);
-- 6b. crm_proposals (migration 0477, #1504) — PRESERVA número, valores,
-- itens, datas e status; redige só o que identifica a PESSOA. Ver o
-- cabeçalho desta migration para o porquê de cada coluna.
-- O PDF que o cliente recebeu (bucket `propostas`, `<org>/<proposta>.pdf`)
-- tem o nome dele impresso: redigir as colunas e deixar o arquivo seria
-- anonimizar a linha e manter o documento. Vai para a mesma fila de expurgo
-- da mídia (passo 7), com o bucket CERTO — a mensagem que levou o PDF
-- aponta para o mesmo caminho, mas o passo 7 só enfileira `whatsapp-media`.
-- Lido ANTES de o passo seguinte zerar `pdf_path`.
insert into storage_redaction_queue (organization_id, request_id, bucket, object_path)
select p_organization_id, p_request_id, 'propostas', pdf_path
from crm_proposals
where organization_id = p_organization_id
and contact_id = p_contact_id
and pdf_path is not null and length(pdf_path) > 0
-- só arquivo DESTA organização: o expurgo nunca alcança o PDF de outra
and pdf_path like p_organization_id::text || '/%'
on conflict (bucket, object_path) do nothing;
update crm_proposals set
destinatario_nome = v_anon_label,
briefing_json = '{}'::jsonb,
resumo_comercial = null,
-- o texto do documento como foi montado e como foi editado à mão: é o
-- conteúdo do PDF, com o mesmo nome dentro.
rendered_snapshot = null,
secoes_editadas = null,
pdf_path = null,
updated_at = now()
where organization_id = p_organization_id
and contact_id = p_contact_id;
get diagnostics v_count = row_count;
v_counts := v_counts || jsonb_build_object('crm_proposals', v_count);
-- CAMPANHAS: o que foi DITO à pessoa e o endereço para onde foi.
update campaign_recipients set
rendered_body = null,
recipient_address = null,
variables = '{}'::jsonb,
last_error_detail = null,
updated_at = now()
where organization_id = p_organization_id
and contact_id = p_contact_id;
get diagnostics v_count = row_count;
v_counts := v_counts || jsonb_build_object('campaign_recipients', v_count);
-- LISTA DE EXCLUSÃO: solta o vínculo e apaga a cauda do telefone.
update campaign_suppressions set
address_tail = null,
reason = null,
contact_id = null
where organization_id = p_organization_id
and contact_id = p_contact_id;
get diagnostics v_count = row_count;
v_counts := v_counts || jsonb_build_object('campaign_suppressions', v_count);
-- 6c. sales — a comanda. PRESERVA valor, status e datas, e NÃO desliga o
-- contato (ver racional completo no baseline, bloco desta função).
update sales set
notes = null,
cancel_reason = case when cancel_reason is null then null else '[redigido]' end,
reverse_reason = case when reverse_reason is null then null else '[redigido]' end,
updated_at = now()
where organization_id = p_organization_id
and contact_id = p_contact_id;
get diagnostics v_count = row_count;
v_counts := v_counts || jsonb_build_object('sales', v_count);
-- 6d. conversation_notes (migration 0483, F3 da #1863) — a nota interna é
-- texto escrito SOBRE a pessoa durante o atendimento, e o anexo dela é mídia
-- ancorada na conversa: os dois entram no alcance do titular. A 0477 já
-- mostrou o desenho (arquivo vai para a fila ANTES de a coluna ser zerada).
-- O bucket é `internal-media`, e não o do passo 7: a nota nunca sobe no
-- `whatsapp-media` (é o bucket do canal do CLIENTE), e enfileirar o caminho
-- num bucket onde ele não está deixaria a remoção apontando para o nada —
-- a mesma falha de não ter anonimizado, um endereço mais para a direita.
-- Por isso os caminhos de nota também NÃO entram em `v_media_paths`: essa
-- lista só existe para o passo 7, que enfileira `whatsapp-media`.
insert into storage_redaction_queue (organization_id, request_id, bucket, object_path)
select p_organization_id, p_request_id, 'internal-media', n.media_storage_path
from conversation_notes n
where n.organization_id = p_organization_id
and n.conversation_id in (
select id from conversations
where contact_id = p_contact_id and organization_id = p_organization_id
)
and n.media_storage_path is not null and length(n.media_storage_path) > 0
and n.media_storage_path like p_organization_id::text || '/%'
on conflict (bucket, object_path) do nothing;
update conversation_notes set
body = '[nota interna anonimizada]',
media_storage_path = null,
media_mime = null,
media_size_bytes = null
where organization_id = p_organization_id
and conversation_id in (
select id from conversations
where contact_id = p_contact_id and organization_id = p_organization_id
);
get diagnostics v_count = row_count;
v_counts := v_counts || jsonb_build_object('conversation_notes', v_count);
-- 7. enqueue media for async deletion (idempotent via unique (bucket, object_path))
if array_length(v_media_paths, 1) > 0 then
insert into storage_redaction_queue (organization_id, request_id, bucket, object_path)
select p_organization_id, p_request_id, 'whatsapp-media', path
from unnest(v_media_paths) as path
where path is not null and length(path) > 0
on conflict (bucket, object_path) do nothing;
end if;
-- 7b. voice_calls — o TELEFONE de quem falou ao telefone (migration 0235).
update voice_calls set
peer_phone = v_anon_label,
owner_user_id = null,
created_by = null,
updated_at = now()
where organization_id = p_organization_id
and contact_id = p_contact_id;
get diagnostics v_count = row_count;
v_counts := v_counts || jsonb_build_object('voice_calls', v_count);
update prospecting_candidates set suppression_salt = gen_random_bytes(32)
where organization_id = p_organization_id
and (contact_id = p_contact_id
or (phone is not null
and regexp_replace(phone, '\D', '', 'g') = any (v_variantes)))
and suppression_salt is null;
update prospecting_candidates set
suppression_place = hmac(convert_to(place_id, 'UTF8'), suppression_salt, 'sha256'),
suppression_phone = case when phone is null then null
else hmac(convert_to(phone, 'UTF8'), suppression_salt, 'sha256') end,
place_id = 'redacted:' || id::text,
phone = null,
data = jsonb_build_object('key', 'redacted:' || id::text,
'name', v_anon_label, 'phone', null, 'website', null,
'category', null, 'address', null, 'maps_url', null,
'rating', null, 'reviews', null, 'emails', '[]'::jsonb, 'socials', '[]'::jsonb),
status = 'skipped', service_boundary = null, error = null, updated_at = now()
where organization_id = p_organization_id
and (contact_id = p_contact_id
or (phone is not null
and regexp_replace(phone, '\D', '', 'g') = any (v_variantes)));
get diagnostics v_count = row_count;
v_counts := v_counts || jsonb_build_object('prospecting_candidates', v_count);
-- agent_cases — o que a IA escreveu SOBRE a pessoa quando travou (migration 0280).
update agent_cases set
title = v_anon_label,
summary = '[resumo anonimizado]',
blocker = '[bloqueio anonimizado]',
context_snapshot = '{}'::jsonb
where organization_id = p_organization_id
and conversation_id in (
select id from conversations
where contact_id = p_contact_id and organization_id = p_organization_id
);
get diagnostics v_count = row_count;
v_counts := v_counts || jsonb_build_object('agent_cases', v_count);
-- agent_case_events — a linha do tempo do caso (migration 0280).
update agent_case_events set
body = null,
metadata = '{}'::jsonb
where organization_id = p_organization_id
and case_id in (
select id from agent_cases
where organization_id = p_organization_id
and conversation_id in (
select id from conversations
where contact_id = p_contact_id and organization_id = p_organization_id
)
);
get diagnostics v_count = row_count;
v_counts := v_counts || jsonb_build_object('agent_case_events', v_count);
-- demandas — o assunto do pedido (migration 0280).
update demandas set
assunto = null
where organization_id = p_organization_id
and contact_id = p_contact_id;
get diagnostics v_count = row_count;
v_counts := v_counts || jsonb_build_object('demandas', v_count);
-- agent_inbox_items — o aviso que leva o texto do caso para a Central (migration 0280/0292).
update agent_inbox_items set
status = 'resolved',
resolved_at = now(),
body = 'Contato anonimizado.',
ref_id = null
where organization_id = p_organization_id
and kind in ('handoff', 'case_stale', 'aviso_de_caso_nao_entregue')
and (
(ref_kind = 'contact' and ref_id = p_contact_id)
or (ref_kind = 'conversation' and ref_id in (
select id from conversations
where contact_id = p_contact_id and organization_id = p_organization_id
))
or (ref_kind = 'agent_case' and ref_id in (
select id from agent_cases
where organization_id = p_organization_id
and conversation_id in (
select id from conversations
where contact_id = p_contact_id and organization_id = p_organization_id
)
))
);
get diagnostics v_count = row_count;
v_counts := v_counts || jsonb_build_object('agent_inbox_items', v_count);
-- agent_case_chat_messages — a consulta interna da equipe à IA SOBRE o caso (migration 0281).
update agent_case_chat_messages set
body = null,
redacted_at = now()
where organization_id = p_organization_id
and contact_id = p_contact_id
and redacted_at is null;
get diagnostics v_count = row_count;
v_counts := v_counts || jsonb_build_object('agent_case_chat_messages', v_count);
-- passagens_de_atendimento — o BRIEFING é sobre a pessoa (migration 0291).
update passagens_de_atendimento set
body = v_anon_label,
title = null,
notes = null,
content = null,
tentativas = '[]'::jsonb
where organization_id = p_organization_id and contact_id = p_contact_id;
get diagnostics v_count = row_count;
v_counts := v_counts || jsonb_build_object('passagens_de_atendimento', v_count);
-- entregas_de_aviso_de_caso — o registro do aviso ao suporte (migration 0292).
update entregas_de_aviso_de_caso set
erro_detalhe = null
where organization_id = p_organization_id
and case_id in (
select id from agent_cases
where organization_id = p_organization_id
and conversation_id in (
select id from conversations
where contact_id = p_contact_id and organization_id = p_organization_id
)
);
get diagnostics v_count = row_count;
v_counts := v_counts || jsonb_build_object('entregas_de_aviso_de_caso', v_count);
-- channel_session_groups.subject — o NOME do grupo, e a FK contact_id aponta
-- para o placeholder do grupo (contacts.kind = 'whatsapp_group'), nunca para
-- o titular real sendo anonimizado neste caminho — mas a FK para contacts e o
-- nome da coluna casam o padrão automático do escopo (migration 0482), e
-- nulificar não perde nada operacional: número, conversa e liga/desliga ficam.
update public.channel_session_groups set
subject = null
where organization_id = p_organization_id
and contact_id = p_contact_id;
get diagnostics v_count = row_count;
v_counts := v_counts || jsonb_build_object('channel_session_groups', v_count);
-- 8. dense audit row
insert into api_audit_log (organization_id, action, actor_user_id, resource_type, resource_id, metadata, bypassed_rls)
values (
p_organization_id,
'lgpd.redact_executed',
null,
'contact',
p_contact_id,
jsonb_build_object(
'cascaded_to', v_counts,
'media_queued', coalesce(array_length(v_media_paths, 1), 0),
'request_id', p_request_id
),
true
);
return jsonb_build_object(
'already_anonymized', false,
'counts', v_counts,
'media_paths', v_media_paths
);
end;
$$;
revoke all on function public.fn_lgpd_cascade_redact_contact(uuid,uuid,uuid) from public, anon, authenticated;
grant execute on function public.fn_lgpd_cascade_redact_contact(uuid,uuid,uuid) to service_role;
-- Cura: mensagens já anonimizadas que guardaram a transcrição.
update public.messages set
media_derived_text = null,
updated_at = now()
where body = '[mensagem anonimizada]'
and media_derived_text is not null;
notify pgrst, 'reload schema';
-- ---- VARREDURA anon: função nova nasce exposta em quem ATUALIZA (migration 0116) ----
--
@@ -45036,6 +45642,43 @@ alter table public.channel_knobs
and (resposta_end_hour is null or resposta_end_hour between 1 and 24)
);
-- ---- atraso humano configurável por conexão (migration 0499) ----
-- 0499 — os quatro números do atraso humano ANTES da primeira bolha
-- (`atraso-humano.ts`) viram knob por conexão (issue #653): NOTAR, POR_CARACTERE,
-- MINIMO e MAXIMO, todos NULL = default em defaults.ts (900/22/1200/7500 — os
-- valores de antes, regressão zero). Nascem em `channel_knobs`, a mesma tabela
-- dos knobs de pacing por conexão (0010 e 0495); nada de tabela nova. O jitter
-- entre bolhas não ganha coluna: ele já é `throttle_ms` + `jitter_max_ms`.
-- Reaplicável: `add column if not exists` e `drop constraint if exists` antes
-- do `add constraint` — o `update.sh` roda o apêndice inteiro a cada atualização.
alter table public.channel_knobs
add column if not exists atraso_notar_ms integer,
add column if not exists ms_por_caractere integer,
add column if not exists atraso_minimo_ms integer,
add column if not exists atraso_maximo_ms integer;
comment on column public.channel_knobs.atraso_notar_ms is
'Parcela fixa do atraso humano (ms): ver a notificação e abrir a conversa. NULL = default (900).';
comment on column public.channel_knobs.ms_por_caractere is
'Taxa de digitação do atraso humano (ms por caractere). NULL = default (22).';
comment on column public.channel_knobs.atraso_minimo_ms is
'Piso do atraso humano (ms). NULL = default (1200).';
comment on column public.channel_knobs.atraso_maximo_ms is
'Teto do atraso humano (ms). NULL = default (7500).';
alter table public.channel_knobs
drop constraint if exists channel_knobs_atraso_humano_saneamento;
alter table public.channel_knobs
add constraint channel_knobs_atraso_humano_saneamento
check (
(atraso_notar_ms is null or (atraso_notar_ms between 0 and 600000))
and (ms_por_caractere is null or (ms_por_caractere between 0 and 1000))
and (atraso_minimo_ms is null or (atraso_minimo_ms between 0 and 600000))
and (atraso_maximo_ms is null or (atraso_maximo_ms between 0 and 600000))
and (atraso_maximo_ms is null or atraso_minimo_ms is null or atraso_maximo_ms >= atraso_minimo_ms)
);
-- ---- 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/
@@ -45077,3 +45720,22 @@ update public.agent_inbox_items i
create unique index if not exists agent_inbox_event_dead_aberto_unico
on public.agent_inbox_items (organization_id, kind, title)
where status = 'open' and kind = 'event_dead';
-- ---- janela de rajada configurável por agente (migration 0498) ----
-- 0498 (#1856, de @webtecnica): a janela que junta mensagens do MESMO contato
-- numa resposta sai da env global `INBOUND_DEBOUNCE_MS` e vira campo da versão
-- do agente. NULL = usa a env da instalação (quem só atualiza não muda nada);
-- 0 desliga; teto 60s. Par drop/add da CHECK para o `update.sh` reaplicar sem
-- 'already exists'. Sem função nova (nada a revogar de anon).
alter table public.ai_agent_versions
drop constraint if exists ai_agent_versions_inbound_debounce_ms_check;
alter table public.ai_agent_versions
add column if not exists inbound_debounce_ms integer;
comment on column public.ai_agent_versions.inbound_debounce_ms is
'Janela de coalescência de rajada inbound em ms para ESTE agente. NULL = usa o INBOUND_DEBOUNCE_MS da instalação; 0 = desliga a coalescência; teto 60s.';
alter table public.ai_agent_versions
add constraint ai_agent_versions_inbound_debounce_ms_check
check (inbound_debounce_ms is null or (inbound_debounce_ms >= 0 and inbound_debounce_ms <= 60000));
@@ -0,0 +1,638 @@
-- 0497 — anonimizar apaga também a TRANSCRIÇÃO da mídia (messages.media_derived_text)
--
-- Achado na triagem do #1988 (@AlecLimaDev), que expõe esta coluna a
-- integrações. `media_derived_text` é o texto que o `media-derive-worker` tira
-- da mídia (transcrição do áudio, OCR/visão da imagem, texto do documento —
-- 0058). Nenhum caminho de anonimização a zerava:
--
-- fn_lgpd_cascade_redact_contact (passo 3) redige body, mídia e metadata
-- fn_redigir_conversas_ao_anonimizar (0391/0494) idem, nas duas passadas
-- (conversas do contato e
-- mensagens de grupo dele)
--
-- e `lib/lgpd/cascata.ts` não passa por `messages`. Resultado medido: o body do
-- áudio vira '[mensagem anonimizada]' e a transcrição dele continua legível —
-- e é lida pelo agente (get-lead-context, inbound-turn, conversa-do-caso,
-- followup/atendimento, mcp/tools/propostas).
--
-- ── O conserto ───────────────────────────────────────────────────────────────
-- Uma linha — `media_derived_text = null` — em cada UPDATE de messages que já
-- redige o body. Os dois corpos partem do corpo ATUAL da main, byte a byte:
-- fn_redigir_conversas_ao_anonimizar ← 0494 (#1973), 2 inserções
-- fn_lgpd_cascade_redact_contact ← 0483 (a última redefinição), 1 inserção
-- Nenhuma outra linha muda. `create or replace` troca o corpo INTEIRO, e um
-- apêndice antigo já reverteu uma cascata de LGPD por partir de corpo velho.
-- `media_derived_status` fica: é vocabulário ('ready'/'failed'/'skipped'), não
-- conteúdo — e é ele que diz ao drain do turno que a derivação terminou.
--
-- ── A cura de quem JÁ foi anonimizado ───────────────────────────────────────
-- O gatilho só dispara na virada de is_anonymized; para quem já virou, a virada
-- passou. O marcador de "esta mensagem já foi anonimizada" é o próprio body
-- '[mensagem anonimizada]' — só a anonimização o escreve. Ele alcança, com um
-- predicado só, as conversas do contato E as mensagens de grupo que o titular
-- escreveu (que não estão nas conversas dele), e poupa quem VOLTOU a escrever
-- (mensagem nova tem body de verdade — a mesma regra que a cura da 0391 segue
-- por `anonymized_at`). Idempotente pelo `is not null`: o update.sh reaplica
-- sem reescrever nada.
--
-- O mesmo marcador é o que `lib/lgpd/cascata.ts` usa na varredura diária, e é
-- ela que fecha a janela restante: um worker de derivação que leu a mídia ANTES
-- da anonimização e grava o texto DEPOIS dela.
create or replace function public.fn_redigir_conversas_ao_anonimizar()
returns trigger
language plpgsql
security definer
set search_path = public, pg_temp
as $f$
declare
v_variantes text[];
v_lid text;
v_msgs_de_grupo uuid[];
begin
insert into public.storage_redaction_queue (organization_id, bucket, object_path)
select distinct new.organization_id, 'whatsapp-media', m.media_storage_path
from public.messages m
where m.organization_id = new.organization_id
and m.conversation_id in (
select c.id from public.conversations c
where c.contact_id = new.id and c.organization_id = new.organization_id)
and m.media_storage_path is not null
and length(m.media_storage_path) > 0
on conflict (bucket, object_path) do nothing;
update public.messages set
body = '[mensagem anonimizada]',
media_url = null,
media_mime = null,
media_size_bytes = null,
media_storage_path = null,
media_derived_text = null,
metadata = '{}'::jsonb,
updated_at = now()
where organization_id = new.organization_id
and conversation_id in (
select c.id from public.conversations c
where c.contact_id = new.id and c.organization_id = new.organization_id);
update public.conversations set
metadata = '{}'::jsonb,
last_message_preview = null,
last_handoff_reason = null,
updated_at = now()
where contact_id = new.id and organization_id = new.organization_id;
update public.lead_checkpoints set
rolling_summary = '[resumo anonimizado]',
commitments = '[]'::jsonb,
objections = '[]'::jsonb,
next_action = null,
declaracao = null
where contact_id = new.id and organization_id = new.organization_id;
-- ── issue #1964 — lead_notes: a memória do agente sobre o contato ─────────
update public.lead_notes set
headline = '(anonimizado)',
body = '(anonimizado)',
embedding = null,
updated_at = now()
where organization_id = new.organization_id
and contact_id = new.id
and (headline is distinct from '(anonimizado)'
or body is distinct from '(anonimizado)'
or embedding is not null);
-- ── issue #1964 — ai_agent_runs.tool_calls: preserva o nome e redige o resto
-- A transformação espelha `redigirToolCalls` (lib/lgpd/cascata.ts): cada
-- passo vira `{ step?, tool_name?, redacted: true, tool_calls: [{ tool_name }] }`.
-- Fica QUAIS ferramentas rodaram e em que passo; sai o texto do modelo, os
-- argumentos e os resultados. Guard no WHERE: só corre quando há passo sem
-- `redacted`, então o `[]` de nascença e a run já redigida não são tocados.
update public.ai_agent_runs set
tool_calls = public.fn_lgpd_redigir_tool_calls(ai_agent_runs.tool_calls)
where ai_agent_runs.organization_id = new.organization_id
and ai_agent_runs.contact_id = new.id
and exists (
select 1 from jsonb_array_elements(ai_agent_runs.tool_calls) s
where coalesce(s->>'redacted', 'false')::boolean is not true
);
-- ── issue #1964 — lead_state: a próxima ação e a qualificação da lead ─────
update public.lead_state set
next_action = null,
qualification = '{}'::jsonb,
updated_at = now()
where organization_id = new.organization_id
and contact_id = new.id
and (next_action is not null
or coalesce(qualification, '{}'::jsonb) <> '{}'::jsonb);
-- ── issue #1964 — contacts.social_identity ───────────────────────────────
-- O índice único parcial `where social_identity is not null` torna anular
-- seguro (a linha sai do índice sem violar unicidade). Guard no WHERE para a
-- varredura não reescrever o que já está anonimizado.
update public.contacts set
social_identity = null,
updated_at = now()
where id = new.id
and organization_id = new.organization_id
and social_identity is not null;
-- Mensagens de grupo escritas pelo titular (0482, ver o cabeçalho daquele bloco).
v_variantes := coalesce(public.fn_telefone_variantes(coalesce(old.phone_number, new.phone_number)), '{}');
v_lid := coalesce(old.wa_lid, new.wa_lid);
select coalesce(array_agg(m.id), '{}')
into v_msgs_de_grupo
from public.messages m
where m.organization_id = new.organization_id
and m.metadata ? 'group_sender'
and (
regexp_replace(coalesce(m.metadata->'group_sender'->>'phone', ''), '\D', '', 'g') = any(v_variantes)
or (v_lid is not null and m.metadata->'group_sender'->>'lid' = v_lid)
);
if cardinality(v_msgs_de_grupo) > 0 then
insert into public.storage_redaction_queue (organization_id, bucket, object_path)
select distinct new.organization_id, 'whatsapp-media', m.media_storage_path
from public.messages m
where m.organization_id = new.organization_id
and m.id = any(v_msgs_de_grupo)
and m.media_storage_path is not null
and length(m.media_storage_path) > 0
on conflict (bucket, object_path) do nothing;
update public.conversations set
last_message_preview = null,
updated_at = now()
where organization_id = new.organization_id
and id in (select m.conversation_id from public.messages m
where m.organization_id = new.organization_id and m.id = any(v_msgs_de_grupo));
update public.messages set
body = '[mensagem anonimizada]',
media_url = null,
media_mime = null,
media_size_bytes = null,
media_storage_path = null,
media_derived_text = null,
metadata = '{}'::jsonb,
updated_at = now()
where organization_id = new.organization_id
and id = any(v_msgs_de_grupo);
end if;
return new;
end
$f$;
-- As DUAS origens de EXECUTE (item 9 do CLAUDE.md): o grant a PUBLIC da criação
-- e o grant nominal a anon do ALTER DEFAULT PRIVILEGES do baseline.
revoke all on function public.fn_redigir_conversas_ao_anonimizar() from public;
revoke execute on function public.fn_redigir_conversas_ao_anonimizar() from anon;
revoke execute on function public.fn_redigir_conversas_ao_anonimizar() from authenticated;
create or replace function public.fn_lgpd_cascade_redact_contact(p_organization_id uuid, p_contact_id uuid, p_request_id uuid) returns jsonb
language plpgsql security definer
set search_path to 'public', 'extensions', 'pg_temp'
as $$
declare
v_already bool;
v_counts jsonb := '{}'::jsonb;
v_media_paths text[] := '{}';
v_anon_label text;
v_count int;
v_variantes text[] := '{}';
begin
perform public.fn_service_lock(p_organization_id,p_contact_id);
select is_anonymized into v_already
from contacts
where id = p_contact_id and organization_id = p_organization_id;
if not found then
raise exception 'contact not found' using errcode = 'P0002';
end if;
if v_already then
return jsonb_build_object('already_anonymized', true, 'counts', v_counts, 'media_paths', v_media_paths);
end if;
v_anon_label := 'Cliente Anonimizado #' || substring(p_contact_id::text from 1 for 8);
select coalesce(public.fn_telefone_variantes(phone_number), '{}')
into v_variantes
from contacts
where id = p_contact_id and organization_id = p_organization_id;
select coalesce(array_agg(distinct media_storage_path) filter (where media_storage_path is not null), '{}')
into v_media_paths
from messages
where organization_id = p_organization_id
and conversation_id in (
select id from conversations
where contact_id = p_contact_id and organization_id = p_organization_id
);
-- 1. contacts (irreversible)
update contacts set
name = v_anon_label,
display_name = v_anon_label,
email = null,
phone_number = null,
cpf_encrypted = null,
cpf_hash = null,
birthdate = null,
is_anonymized = true,
anonymized_at = now(),
consent = '{}'::jsonb,
source_metadata = '{}'::jsonb,
tags = '{}'::text[],
updated_at = now()
where id = p_contact_id and organization_id = p_organization_id;
get diagnostics v_count = row_count;
v_counts := v_counts || jsonb_build_object('contacts', v_count);
-- 2. conversations metadata + preview strip
update conversations set
metadata = '{}'::jsonb,
last_message_preview = null,
last_handoff_reason = null,
updated_at = now()
where contact_id = p_contact_id and organization_id = p_organization_id;
get diagnostics v_count = row_count;
v_counts := v_counts || jsonb_build_object('conversations', v_count);
-- 3. messages: redact body + null media + strip metadata (preserve status/timestamps/conversation_id)
update messages set
body = '[mensagem anonimizada]',
media_url = null,
media_mime = null,
media_size_bytes = null,
media_storage_path = null,
media_derived_text = null,
metadata = '{}'::jsonb,
updated_at = now()
where organization_id = p_organization_id
and conversation_id in (
select id from conversations
where contact_id = p_contact_id and organization_id = p_organization_id
);
get diagnostics v_count = row_count;
v_counts := v_counts || jsonb_build_object('messages', v_count);
-- 4. crm_lead_activities — strip payload, metadata E reason (migration 0071).
update crm_lead_activities set
payload = '{}'::jsonb,
metadata = '{}'::jsonb,
reason = null
where organization_id = p_organization_id
and (
contact_id = p_contact_id
or lead_id in (
select lead_id from crm_lead_links
where target_kind = 'contact'
and target_id = p_contact_id
and organization_id = p_organization_id
)
or lead_id in (
select id from crm_leads
where contact_id = p_contact_id and organization_id = p_organization_id
)
);
get diagnostics v_count = row_count;
v_counts := v_counts || jsonb_build_object('activities', v_count);
-- 5. crm_leads — strip title/description/custom_fields/source_metadata/tags but PRESERVE pipeline/stage/value
update crm_leads set
title = v_anon_label,
description = null,
custom_fields = '{}'::jsonb,
source_metadata = '{}'::jsonb,
tags = '{}'::text[],
updated_at = now()
where organization_id = p_organization_id
and (
contact_id = p_contact_id
or id in (
select lead_id from crm_lead_links
where target_kind = 'contact'
and target_id = p_contact_id
and organization_id = p_organization_id
)
);
get diagnostics v_count = row_count;
v_counts := v_counts || jsonb_build_object('leads', v_count);
-- 6. orders — PRESERVE values + status + timestamps. Strip personal fields from payload jsonb
-- and replace customer_external_id with null (FK-safe; soft de-link). Keep contact_id null.
update orders set
payload = (coalesce(payload, '{}'::jsonb))
- 'customer'
- 'customer_name'
- 'customer_email'
- 'customer_phone'
- 'shipping_address'
- 'billing_address'
- 'contact_identification',
customer_external_id = null,
contact_id = null,
is_anonymized = true,
updated_at = now()
where organization_id = p_organization_id
and contact_id = p_contact_id;
get diagnostics v_count = row_count;
v_counts := v_counts || jsonb_build_object('orders', v_count);
-- 6b. crm_proposals (migration 0477, #1504) — PRESERVA número, valores,
-- itens, datas e status; redige só o que identifica a PESSOA. Ver o
-- cabeçalho desta migration para o porquê de cada coluna.
-- O PDF que o cliente recebeu (bucket `propostas`, `<org>/<proposta>.pdf`)
-- tem o nome dele impresso: redigir as colunas e deixar o arquivo seria
-- anonimizar a linha e manter o documento. Vai para a mesma fila de expurgo
-- da mídia (passo 7), com o bucket CERTO — a mensagem que levou o PDF
-- aponta para o mesmo caminho, mas o passo 7 só enfileira `whatsapp-media`.
-- Lido ANTES de o passo seguinte zerar `pdf_path`.
insert into storage_redaction_queue (organization_id, request_id, bucket, object_path)
select p_organization_id, p_request_id, 'propostas', pdf_path
from crm_proposals
where organization_id = p_organization_id
and contact_id = p_contact_id
and pdf_path is not null and length(pdf_path) > 0
-- só arquivo DESTA organização: o expurgo nunca alcança o PDF de outra
and pdf_path like p_organization_id::text || '/%'
on conflict (bucket, object_path) do nothing;
update crm_proposals set
destinatario_nome = v_anon_label,
briefing_json = '{}'::jsonb,
resumo_comercial = null,
-- o texto do documento como foi montado e como foi editado à mão: é o
-- conteúdo do PDF, com o mesmo nome dentro.
rendered_snapshot = null,
secoes_editadas = null,
pdf_path = null,
updated_at = now()
where organization_id = p_organization_id
and contact_id = p_contact_id;
get diagnostics v_count = row_count;
v_counts := v_counts || jsonb_build_object('crm_proposals', v_count);
-- CAMPANHAS: o que foi DITO à pessoa e o endereço para onde foi.
update campaign_recipients set
rendered_body = null,
recipient_address = null,
variables = '{}'::jsonb,
last_error_detail = null,
updated_at = now()
where organization_id = p_organization_id
and contact_id = p_contact_id;
get diagnostics v_count = row_count;
v_counts := v_counts || jsonb_build_object('campaign_recipients', v_count);
-- LISTA DE EXCLUSÃO: solta o vínculo e apaga a cauda do telefone.
update campaign_suppressions set
address_tail = null,
reason = null,
contact_id = null
where organization_id = p_organization_id
and contact_id = p_contact_id;
get diagnostics v_count = row_count;
v_counts := v_counts || jsonb_build_object('campaign_suppressions', v_count);
-- 6c. sales — a comanda. PRESERVA valor, status e datas, e NÃO desliga o
-- contato (ver racional completo no baseline, bloco desta função).
update sales set
notes = null,
cancel_reason = case when cancel_reason is null then null else '[redigido]' end,
reverse_reason = case when reverse_reason is null then null else '[redigido]' end,
updated_at = now()
where organization_id = p_organization_id
and contact_id = p_contact_id;
get diagnostics v_count = row_count;
v_counts := v_counts || jsonb_build_object('sales', v_count);
-- 6d. conversation_notes (migration 0483, F3 da #1863) — a nota interna é
-- texto escrito SOBRE a pessoa durante o atendimento, e o anexo dela é mídia
-- ancorada na conversa: os dois entram no alcance do titular. A 0477 já
-- mostrou o desenho (arquivo vai para a fila ANTES de a coluna ser zerada).
-- O bucket é `internal-media`, e não o do passo 7: a nota nunca sobe no
-- `whatsapp-media` (é o bucket do canal do CLIENTE), e enfileirar o caminho
-- num bucket onde ele não está deixaria a remoção apontando para o nada —
-- a mesma falha de não ter anonimizado, um endereço mais para a direita.
-- Por isso os caminhos de nota também NÃO entram em `v_media_paths`: essa
-- lista só existe para o passo 7, que enfileira `whatsapp-media`.
insert into storage_redaction_queue (organization_id, request_id, bucket, object_path)
select p_organization_id, p_request_id, 'internal-media', n.media_storage_path
from conversation_notes n
where n.organization_id = p_organization_id
and n.conversation_id in (
select id from conversations
where contact_id = p_contact_id and organization_id = p_organization_id
)
and n.media_storage_path is not null and length(n.media_storage_path) > 0
and n.media_storage_path like p_organization_id::text || '/%'
on conflict (bucket, object_path) do nothing;
update conversation_notes set
body = '[nota interna anonimizada]',
media_storage_path = null,
media_mime = null,
media_size_bytes = null
where organization_id = p_organization_id
and conversation_id in (
select id from conversations
where contact_id = p_contact_id and organization_id = p_organization_id
);
get diagnostics v_count = row_count;
v_counts := v_counts || jsonb_build_object('conversation_notes', v_count);
-- 7. enqueue media for async deletion (idempotent via unique (bucket, object_path))
if array_length(v_media_paths, 1) > 0 then
insert into storage_redaction_queue (organization_id, request_id, bucket, object_path)
select p_organization_id, p_request_id, 'whatsapp-media', path
from unnest(v_media_paths) as path
where path is not null and length(path) > 0
on conflict (bucket, object_path) do nothing;
end if;
-- 7b. voice_calls — o TELEFONE de quem falou ao telefone (migration 0235).
update voice_calls set
peer_phone = v_anon_label,
owner_user_id = null,
created_by = null,
updated_at = now()
where organization_id = p_organization_id
and contact_id = p_contact_id;
get diagnostics v_count = row_count;
v_counts := v_counts || jsonb_build_object('voice_calls', v_count);
update prospecting_candidates set suppression_salt = gen_random_bytes(32)
where organization_id = p_organization_id
and (contact_id = p_contact_id
or (phone is not null
and regexp_replace(phone, '\D', '', 'g') = any (v_variantes)))
and suppression_salt is null;
update prospecting_candidates set
suppression_place = hmac(convert_to(place_id, 'UTF8'), suppression_salt, 'sha256'),
suppression_phone = case when phone is null then null
else hmac(convert_to(phone, 'UTF8'), suppression_salt, 'sha256') end,
place_id = 'redacted:' || id::text,
phone = null,
data = jsonb_build_object('key', 'redacted:' || id::text,
'name', v_anon_label, 'phone', null, 'website', null,
'category', null, 'address', null, 'maps_url', null,
'rating', null, 'reviews', null, 'emails', '[]'::jsonb, 'socials', '[]'::jsonb),
status = 'skipped', service_boundary = null, error = null, updated_at = now()
where organization_id = p_organization_id
and (contact_id = p_contact_id
or (phone is not null
and regexp_replace(phone, '\D', '', 'g') = any (v_variantes)));
get diagnostics v_count = row_count;
v_counts := v_counts || jsonb_build_object('prospecting_candidates', v_count);
-- agent_cases — o que a IA escreveu SOBRE a pessoa quando travou (migration 0280).
update agent_cases set
title = v_anon_label,
summary = '[resumo anonimizado]',
blocker = '[bloqueio anonimizado]',
context_snapshot = '{}'::jsonb
where organization_id = p_organization_id
and conversation_id in (
select id from conversations
where contact_id = p_contact_id and organization_id = p_organization_id
);
get diagnostics v_count = row_count;
v_counts := v_counts || jsonb_build_object('agent_cases', v_count);
-- agent_case_events — a linha do tempo do caso (migration 0280).
update agent_case_events set
body = null,
metadata = '{}'::jsonb
where organization_id = p_organization_id
and case_id in (
select id from agent_cases
where organization_id = p_organization_id
and conversation_id in (
select id from conversations
where contact_id = p_contact_id and organization_id = p_organization_id
)
);
get diagnostics v_count = row_count;
v_counts := v_counts || jsonb_build_object('agent_case_events', v_count);
-- demandas — o assunto do pedido (migration 0280).
update demandas set
assunto = null
where organization_id = p_organization_id
and contact_id = p_contact_id;
get diagnostics v_count = row_count;
v_counts := v_counts || jsonb_build_object('demandas', v_count);
-- agent_inbox_items — o aviso que leva o texto do caso para a Central (migration 0280/0292).
update agent_inbox_items set
status = 'resolved',
resolved_at = now(),
body = 'Contato anonimizado.',
ref_id = null
where organization_id = p_organization_id
and kind in ('handoff', 'case_stale', 'aviso_de_caso_nao_entregue')
and (
(ref_kind = 'contact' and ref_id = p_contact_id)
or (ref_kind = 'conversation' and ref_id in (
select id from conversations
where contact_id = p_contact_id and organization_id = p_organization_id
))
or (ref_kind = 'agent_case' and ref_id in (
select id from agent_cases
where organization_id = p_organization_id
and conversation_id in (
select id from conversations
where contact_id = p_contact_id and organization_id = p_organization_id
)
))
);
get diagnostics v_count = row_count;
v_counts := v_counts || jsonb_build_object('agent_inbox_items', v_count);
-- agent_case_chat_messages — a consulta interna da equipe à IA SOBRE o caso (migration 0281).
update agent_case_chat_messages set
body = null,
redacted_at = now()
where organization_id = p_organization_id
and contact_id = p_contact_id
and redacted_at is null;
get diagnostics v_count = row_count;
v_counts := v_counts || jsonb_build_object('agent_case_chat_messages', v_count);
-- passagens_de_atendimento — o BRIEFING é sobre a pessoa (migration 0291).
update passagens_de_atendimento set
body = v_anon_label,
title = null,
notes = null,
content = null,
tentativas = '[]'::jsonb
where organization_id = p_organization_id and contact_id = p_contact_id;
get diagnostics v_count = row_count;
v_counts := v_counts || jsonb_build_object('passagens_de_atendimento', v_count);
-- entregas_de_aviso_de_caso — o registro do aviso ao suporte (migration 0292).
update entregas_de_aviso_de_caso set
erro_detalhe = null
where organization_id = p_organization_id
and case_id in (
select id from agent_cases
where organization_id = p_organization_id
and conversation_id in (
select id from conversations
where contact_id = p_contact_id and organization_id = p_organization_id
)
);
get diagnostics v_count = row_count;
v_counts := v_counts || jsonb_build_object('entregas_de_aviso_de_caso', v_count);
-- channel_session_groups.subject — o NOME do grupo, e a FK contact_id aponta
-- para o placeholder do grupo (contacts.kind = 'whatsapp_group'), nunca para
-- o titular real sendo anonimizado neste caminho — mas a FK para contacts e o
-- nome da coluna casam o padrão automático do escopo (migration 0482), e
-- nulificar não perde nada operacional: número, conversa e liga/desliga ficam.
update public.channel_session_groups set
subject = null
where organization_id = p_organization_id
and contact_id = p_contact_id;
get diagnostics v_count = row_count;
v_counts := v_counts || jsonb_build_object('channel_session_groups', v_count);
-- 8. dense audit row
insert into api_audit_log (organization_id, action, actor_user_id, resource_type, resource_id, metadata, bypassed_rls)
values (
p_organization_id,
'lgpd.redact_executed',
null,
'contact',
p_contact_id,
jsonb_build_object(
'cascaded_to', v_counts,
'media_queued', coalesce(array_length(v_media_paths, 1), 0),
'request_id', p_request_id
),
true
);
return jsonb_build_object(
'already_anonymized', false,
'counts', v_counts,
'media_paths', v_media_paths
);
end;
$$;
revoke all on function public.fn_lgpd_cascade_redact_contact(uuid,uuid,uuid) from public, anon, authenticated;
grant execute on function public.fn_lgpd_cascade_redact_contact(uuid,uuid,uuid) to service_role;
-- Cura: mensagens já anonimizadas que guardaram a transcrição.
update public.messages set
media_derived_text = null,
updated_at = now()
where body = '[mensagem anonimizada]'
and media_derived_text is not null;
notify pgrst, 'reload schema';
@@ -0,0 +1,40 @@
-- ═══ Debounce de rajada configurável por agente (0498) ═══
--
-- Até aqui a janela que junta as mensagens do MESMO contato numa resposta só
-- era só a env do worker `INBOUND_DEBOUNCE_MS` (default 8000, em
-- lib/agent-engine/env.ts): global à instalação, invisível na tela e exigindo
-- acesso à VPS para mudar. Esta migration leva o valor para a versão do agente
-- (`ai_agent_versions.inbound_debounce_ms`).
--
-- Sem semântica explícita é fácil errar o desenho, então a regra fica escrita:
-- - NULL (default) = usa o `INBOUND_DEBOUNCE_MS` da instalação. Quem só
-- atualiza não muda de comportamento nenhum — regressão zero.
-- - 0 desliga a coalescência de rajada para aquele agente (job imediato).
-- - 1..60000 define a janela em milissegundos, com TETO em 60s para ninguém
-- travar o atendimento sem querer.
-- O teto é reforçado no leitor do worker (`debounceEfetivo` em debounce.ts):
-- a constraint abaixo é a cerca do banco, mas quem decide de verdade é o
-- TypeScript, que clampa o valor contra o teto mesmo para dados sujos.
--
-- Por que coluna e não jsonb: o resto da config "Limites do atendimento" da
-- versão (`max_steps`, `token_budget`, `history_message_window`, …) vive em
-- colunas tipadas nesta tabela, e o PATCH/INSERT da versão as mapeia campo a
-- campo. Nascer jsonb aqui seria divergir do vizinho sem CHECK forte nenhum.
alter table public.ai_agent_versions
drop constraint if exists ai_agent_versions_inbound_debounce_ms_check;
alter table public.ai_agent_versions
add column if not exists inbound_debounce_ms integer;
comment on column public.ai_agent_versions.inbound_debounce_ms is
'Janela de coalescência de rajada inbound em ms para ESTE agente. NULL = usa o INBOUND_DEBOUNCE_MS da instalação; 0 = desliga a coalescência; teto 60s.';
-- 0..60000 (0 desliga; 60000 = 60s, o teto que impede travar o atendimento).
-- O `drop … if exists` antes do `add` torna a migration reaplicável: o
-- `update.sh` de quem já aplicou a 0498 roda o apêndice do baseline de novo, e
-- `add constraint` sem guarda quebraria com 'already exists'. Mesmo par no
-- apêndice do baseline.sql.
alter table public.ai_agent_versions
add constraint ai_agent_versions_inbound_debounce_ms_check
check (inbound_debounce_ms is null or (inbound_debounce_ms >= 0 and inbound_debounce_ms <= 60000));
@@ -0,0 +1,52 @@
-- ═══ Atraso humano configurável por conexão (0499) ═══
--
-- Os quatro números que governam o atraso humano ANTES da primeira bolha
-- (`lib/agent-engine/agent/atraso-humano.ts`) eram fixos em código: NOTAR
-- (~900ms), POR_CARACTERE (~22ms), MINIMO (~1200ms) e MAXIMO (~7500ms), sem
-- superfície. Uma clínica e um e-commerce querem ritmos diferentes, e os dois
-- recebiam o mesmo valor cravado (issue #653, do achado no PR #644).
--
-- Estes quatro knobs nascem em `channel_knobs` — a MESMA tabela que já guarda
-- `throttle_ms`/`jitter_max_ms` (0010) e `resposta_*` (0495) por conexão.
-- Coluna NULL = cai no default conservador em `defaults.ts`, que espelha os
-- valores de antes: regressão zero para quem não configurou.
--
-- Nenhuma migration nova se o mecanismo knob-por-conexão já existisse — ele
-- existe, e é `channel_knobs`. Estes são só quatro colunas novas na tabela
-- existente, com o MESMO contrato das vizinhas (nullable, sem default).
--
-- O jitter entre bolhas (`1200 + rand*800` em `inbound-turn.ts`) NÃO ganha
-- coluna nova: ele já é `throttle_ms + jitter_max_ms` (defaults 1200/800) —
-- o que mudou foi o call site passar a LER os knobs da conexão em vez do
-- literal. O preenchimento acima e a exposição na ficha Anti-ban valem para
-- o par.
alter table public.channel_knobs
add column if not exists atraso_notar_ms integer,
add column if not exists ms_por_caractere integer,
add column if not exists atraso_minimo_ms integer,
add column if not exists atraso_maximo_ms integer;
comment on column public.channel_knobs.atraso_notar_ms is
'Parcela fixa do atraso humano (ms): ver a notificação e abrir a conversa. NULL = default (900).';
comment on column public.channel_knobs.ms_por_caractere is
'Taxa de digitação do atraso humano (ms por caractere). NULL = default (22).';
comment on column public.channel_knobs.atraso_minimo_ms is
'Piso do atraso humano (ms). NULL = default (1200).';
comment on column public.channel_knobs.atraso_maximo_ms is
'Teto do atraso humano (ms). NULL = default (7500).';
-- Sanidade: valores positivos e dentro do mesmo teto dos vizinhos (intervalMaxMs).
-- O `drop … if exists` antes do `add` é o que torna o apêndice reaplicável.
alter table public.channel_knobs
drop constraint if exists channel_knobs_atraso_humano_saneamento;
alter table public.channel_knobs
add constraint channel_knobs_atraso_humano_saneamento
check (
(atraso_notar_ms is null or (atraso_notar_ms between 0 and 600000))
and (ms_por_caractere is null or (ms_por_caractere between 0 and 1000))
and (atraso_minimo_ms is null or (atraso_minimo_ms between 0 and 600000))
and (atraso_maximo_ms is null or (atraso_maximo_ms between 0 and 600000))
and (atraso_maximo_ms is null or atraso_minimo_ms is null or atraso_maximo_ms >= atraso_minimo_ms)
);
+3
View File
@@ -485,3 +485,6 @@ To re-apply on a fresh Supabase project, replay the migrations in version order
| `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 (#1983, de @suporteubere99-coder).** 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, a prospecção e o `followup_turn` (retomada de conversa parada). Duas colunas nullable, `resposta_start_hour`/`resposta_end_hour`, sem DEFAULT: **NULL herda a janela de disparo, coluna a coluna** (`loadChannelKnobs` em `lib/agent-engine/pacing/store.ts` e `effectiveKnobs` em `lib/ai/pacing-knobs.ts`, a mesma regra que a ficha Anti-ban mostra no placeholder) — quem só atualiza não muda de operação. CHECK 0..23 / 1..24 com `drop … if exists` antes do `add`. Um bloco `do` guardado renomeia `reengajar_start_hour`/`reengajar_end_hour` para o nome novo onde a primeira versão do PR (nome antigo) já tinha sido aplicada. A separação vive em `PacingInput.resposta` (omitido = disparo: todo chamador fora do turno de resposta fica na janela de disparo); só `inbound_turn`/`case_reply_turn` passam `true` (`eTurnoDeResposta` em `inbound-turn.ts`), inclusive na pergunta do roteiro que sai no mesmo turno. `nextDayOpen` segue na janela de disparo: cap diário é proteção anti-ban. Cap, warm-up e throttle continuam para os dois lados. Sem função nova. Gates: `lib/agent-engine/pacing/janela-de-resposta.test.ts` e `tests/unit/protecao-de-envio-aceita-data-em-branco.test.ts` (0/24 aceito, 22/7 recusado com 422). |
| `20260930130000` | `0496_org_operante_e_suspensao_tipada` | **A suspensão que suspende (spec cobrança do revendedor, PR 1).** Suspender só tirava a pessoa da tela: a rota fazia leitura, UPDATE e `event_log` sem await em três passos soltos, nada parava jobs `pending` nem mensagens `queued`, e `status` era gravável pelo PostgREST por qualquer platform admin (`orgs_write_platform_admin` aceita `fn_is_platform_admin()`, que ignora o scope — um `support_readonly` reativava uma suspensa). (A) `organizations.suspended_kind` (`administrativa`/`cobranca`), backfill antes do CHECK, SEM CHECK de coerência com `status` (o `lgpd-redact-worker` troca para `redacted` sem limpar o tipo; regra de leitura em `lib/organizacao/operante.ts`) e `fn_org_operante(uuid)`, a régua SQL do predicado. (B) Gatilho `trg_organizacao_estado_so_pelo_servidor` (invoker, molde `fn_meet_stamp`): `authenticated`/`anon` não inserem organização nem mudam `status`, `suspended_*` ou `created_by` (42501). (C0) `fn_org_parada_descarta_fila` (invoker, EXECUTE revogado até de `service_role`; só as duas funções de estado a chamam): jobs `pending` → `failed`/`org_nao_operante` e, na mesma CTE, o que dependia deles — rascunho `approved` → `failed`/`org_suspensa`, link do Meet → `state failed` + aviso na Central (`fn_meet_notice`) — e o evento `turn_discarded` para a inscrição de follow-up parada num nó `action`. (C) `fn_suspender_organizacao` (definer, uma transação, `for update`): chama a C0, mensagens `queued` → `failed`/`org_suspensa`, `tenant.suspended` no `event_log` na mesma transação; a administrativa prevalece. (D) kind `org_reativada` no bloco único de `agent_inbox_items_kind_check` (lista completa do baseline, `kind-check-migration-x-baseline`). (E) `fn_reativar_organizacao` (definer): exige o tipo (suspensão nula vale como administrativa), zera a suspensão, falha `pending` remanescente e abre UM item `org_reativada` com a contagem de conversas (fora grupos) com mensagem durante a suspensão — nada é reprocessado. (F) `fn_claim_due_followup_enrollments` (definição vigente da 0308 + `exists` de org `active` na CTE `orgs`): o motor de follow-up não reclama inscrição de org parada nem toca o lease; na reativação ela RETOMA, e a de nó `action` com `turn_discarded` ganha turno novo em vez de esgotar o dead-man. (G) `fn_followup_generation_write` (definição vigente da 0488): recusa `turn_discarded` vindo da sessão (`auth.uid()`, 42501 `followup_step_internal`) — só o servidor grava o evento que faz o motor enfileirar. Nenhuma das duas cita `cobranca_assinaturas` (PR 2). EXECUTE revogado de `public`, `anon` e `authenticated`; só `service_role`. Apêndice antes da VARREDURA anon (seção D editada no lugar). Gate: `tests/invariants/org-suspensa.test.ts`, `tests/invariants/followup-org-suspensa.test.ts` e o par `organizations.suspended_kind` × `TIPOS_DE_SUSPENSAO` em `tests/invariants/vocabulario-banco-x-typescript.test.ts`. |
| `20260930140000` | `0497_lgpd_anonimizar_apaga_transcricao` | **Anonimizar apaga também a TRANSCRIÇÃO da mídia (`messages.media_derived_text`).** Achado na triagem do #1988 (@AlecLimaDev). A coluna (0058, gravada pelo `media-derive-worker`) não era zerada por nenhum caminho: o body do áudio virava `[mensagem anonimizada]` e a transcrição continuava legível para o agente. Uma linha `media_derived_text = null` em cada UPDATE de messages de `fn_redigir_conversas_ao_anonimizar` (corpo da 0494, 2 passadas) e de `fn_lgpd_cascade_redact_contact` (corpo da 0483, passo 3) — nenhuma outra linha muda. Cura idempotente das mensagens já anonimizadas pelo marcador do body (alcança grupos e poupa quem voltou). A varredura diária de `lib/lgpd/cascata.ts` ganha o mesmo passo, e fecha a corrida do worker que grava o texto depois da anonimização. |
| `20260930150000` | `0498_debounce_rajada_configuravel` | **A janela de rajada do WhatsApp vira campo da versão do agente (#1856).** A janela que junta mensagens do MESMO contato numa resposta só era só a env do worker `INBOUND_DEBOUNCE_MS` (global, invisível na tela, exige VPS). Coluna `ai_agent_versions.inbound_debounce_ms` (integer, sem DEFAULT): NULL = usa a env da instalação (regressão zero para quem só atualiza), 0 desliga a coalescência, 1..60000 define a janela em ms com TETO de 60s — o teto é reforçado no leitor do worker (`debounceEfetivo` em `lib/agent-engine/edge/crm/debounce.ts`) e com CHECK `0..60000` no banco (par drop/add pra reaplicar). O drain lê a config do agente da conversa (`loadConversationAgentConfig`) e usa o valor do agente no lugar do literal. Gate: `lib/agent-engine/edge/crm/debounce.test.ts` (default = env, campo = valor, teto clamp no limite). |
| `20260930160000` | `0499_atraso_humano_por_conexao` | **O atraso humano vira configuração por conexão (issue #653).** Os quatro números que governam o atraso ANTES da primeira bolha (`atraso_notar_ms`/`ms_por_caractere`/`atraso_minimo_ms`/`atraso_maximo_ms`) saem do literal em `atraso-humano.ts` e passam a ser 4 colunas NULL em `channel_knobs` (a MESMA tabela dos knobs de pacing por conexão — 0010 e 0495; nada de tabela nova). NULL = default conservador em `defaults.ts` (900/22/1200/7500 — os valores de antes: regressão zero). O jitter entre bolhas (`1200 + rand*800` em `inbound-turn.ts`) NÃO ganha coluna: ele já é `throttle_ms` + `jitter_max_ms` (defaults 1200/800) e passa apenas a LER os knobs da conexão em vez do literal. CHECK de sanidade (`atraso_maximo_ms >= atraso_minimo_ms`, teto intervalMaxMs); exposição na ficha Anti-ban via `POST/GET /api/v1/ai/pacing`. Gate: `tests/unit/atraso-humano-knobs.test.ts`. |
@@ -0,0 +1,185 @@
import { randomUUID } from "node:crypto";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import pg from "pg";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
/**
* ANONIMIZAR APAGA A TRANSCRIÇÃO DA MÍDIA (migration 0497, triagem do #1988).
*
* `messages.media_derived_text` é o texto que o `media-derive-worker` tira da
* mídia — a transcrição do áudio, o OCR da imagem. Até a 0497, a anonimização
* trocava o body por '[mensagem anonimizada]' e deixava a transcrição legível,
* e é ela que o agente lê do áudio (get-lead-context, inbound-turn…).
*
* Prova, no Postgres real e pelo COMPORTAMENTO:
* - a virada de is_anonymized (gatilho da 0391/0494, a porta dos dois
* caminhos) zera a transcrição das conversas do contato E das mensagens de
* grupo que ele escreveu;
* - o pedido formal (`fn_lgpd_cascade_redact_contact`) chega ao mesmo estado;
* - a mensagem de OUTRO contato da mesma org fica intacta;
* - a cura do apêndice (lida do `baseline.sql`, não copiada) alcança quem JÁ
* era anonimizado e poupa a mensagem nova de quem voltou a escrever.
*
* O passo 3 da RPC não tem prova por comportamento própria: a RPC vira
* is_anonymized no passo 1 e o gatilho zera a coluna antes do passo 3. Quem
* vigia aquela linha é o último caso deste arquivo, pelo corpo instalado.
*
* ─── Por que a régua por COLUNA mora aqui ───────────────────────────────────
* `lgpd-redact-unificado-alcanca-pelo-catalogo` e
* `lgpd-cascata-alcanca-quem-guarda-pessoa` decidem por TABELA: um comando em
* `messages` basta para ela contar como coberta, e foi assim que esta coluna
* passou meses legível com os dois verdes (o `PADRAO_PII` do segundo nem casa
* `media_derived_text`). Os dois arquivos estão sob o congelamento de
* `tests/invariants/**`, então a régua nova nasce aqui.
*/
const pool = new pg.Pool({
connectionString: `postgresql://postgres:postgres@127.0.0.1:${process.env.TEST_DB_PORT}/postgres`,
});
afterAll(() => pool.end());
const q = (text: string, args: unknown[] = []) => pool.query(text, args);
const ORG = randomUUID();
const SESSAO = randomUUID();
const ALVO = randomUUID(); // anonimizado pela virada direta (o UPDATE do botão)
const PELO_PEDIDO = randomUUID(); // anonimizado pela RPC do pedido formal
const VIZINHO = randomUUID(); // mesma org, NÃO anonimizado
const JA_ANONIMIZADO = randomUUID(); // anonimizado antes da 0497 — alvo da cura
const GRUPO = randomUUID(); // contato placeholder do grupo
const TELEFONE_DO_ALVO = "+5511987650497";
const conversaDe = new Map<string, string>();
const TRANSCRICAO = "oi, aqui é a Maria Souza, meu CPF é 52998224725";
async function audio(contato: string, body: string | null = null, metadata: object = {}) {
const id = randomUUID();
await q(
`insert into messages(id,organization_id,conversation_id,channel_session_id,contact_id,type,direction,status,
sent_via,body,media_storage_path,media_derived_text,media_derived_status,sent_at,metadata)
values($1,$2,$3,$4,$5,'audio','inbound','delivered','external_device',$6,$7,$8,'ready',now(),$9::jsonb)`,
[id, ORG, conversaDe.get(contato), SESSAO, contato, body, `${ORG}/${id}.ogg`, TRANSCRICAO, JSON.stringify(metadata)],
);
return id;
}
async function transcricao(id: string): Promise<string | null> {
return (await q("select media_derived_text from messages where id=$1", [id])).rows[0].media_derived_text;
}
let doAlvo: string;
let doGrupo: string;
let doPedido: string;
let doVizinho: string;
let residuo: string;
let deQuemVoltou: string;
beforeAll(async () => {
await q("insert into organizations(id,slug,legal_name,display_name) values($1::uuid,$1::text,'Transcrição LGPD','Transcrição LGPD')", [ORG]);
await q(
"insert into channel_sessions(id,organization_id,waha_session_name,webhook_secret_encrypted) values($1::uuid,$2,$1::text,'\\x00'::bytea)",
[SESSAO, ORG],
);
for (const [id, nome, telefone] of [
[ALVO, "Alvo", TELEFONE_DO_ALVO],
[PELO_PEDIDO, "Pelo Pedido", "+5521911110497"],
[VIZINHO, "Vizinho", "+5531922220497"],
] as const) {
await q("insert into contacts(id,organization_id,name,display_name,phone_number) values($1,$2,$3,$3,$4)", [id, ORG, nome, telefone]);
}
// Já anonimizado ANTES da 0497: nasce com is_anonymized (o gatilho só dispara na virada).
await q(
"insert into contacts(id,organization_id,name,display_name,is_anonymized,anonymized_at) values($1,$2,'Cliente Anonimizado','Cliente Anonimizado',true,now())",
[JA_ANONIMIZADO, ORG],
);
await q(
"insert into contacts(id,organization_id,name,display_name,kind,source,source_metadata) values($1,$2,'Grupo','Grupo','whatsapp_group','whatsapp_group',jsonb_build_object('group_chat_id','120363000000000497@g.us'))",
[GRUPO, ORG],
);
for (const contato of [ALVO, PELO_PEDIDO, VIZINHO, JA_ANONIMIZADO, GRUPO]) {
const conversa = randomUUID();
conversaDe.set(contato, conversa);
await q(
"insert into conversations(id,organization_id,contact_id,channel_session_id,status,is_group) values($1,$2,$3,$4,'open',$5)",
[conversa, ORG, contato, SESSAO, contato === GRUPO],
);
}
doAlvo = await audio(ALVO);
doGrupo = await audio(GRUPO, null, { group_sender: { name: "Alvo", phone: TELEFONE_DO_ALVO, lid: null } });
doPedido = await audio(PELO_PEDIDO);
doVizinho = await audio(VIZINHO);
// O estado que a main deixava: body redigido, transcrição legível.
residuo = await audio(JA_ANONIMIZADO, "[mensagem anonimizada]");
// Quem voltou a escrever depois de anonimizado (religado pelo LID): body de verdade.
deQuemVoltou = await audio(JA_ANONIMIZADO, "voltei, quero o orçamento");
});
describe("LGPD: anonimizar apaga a transcrição da mídia (0497)", () => {
it("antes: todas as mensagens guardam a transcrição — o experimento tem massa", async () => {
for (const id of [doAlvo, doGrupo, doPedido, doVizinho, residuo, deQuemVoltou]) {
expect(await transcricao(id)).toBe(TRANSCRICAO);
}
});
it("⭐ a virada de is_anonymized zera a transcrição da conversa do contato E da mensagem de grupo dele", async () => {
await q(
`update contacts set name=null, display_name='Contato Anonimizado', phone_number=null,
is_anonymized=true, anonymized_at=now(), updated_at=now()
where organization_id=$1 and id=$2`,
[ORG, ALVO],
);
expect(await transcricao(doAlvo)).toBeNull();
expect(await transcricao(doGrupo)).toBeNull();
const m = (await q("select body, media_derived_status from messages where id=$1", [doAlvo])).rows[0];
expect(m.body).toBe("[mensagem anonimizada]");
// O status é vocabulário, não conteúdo: é ele que diz ao drain que a derivação terminou.
expect(m.media_derived_status).toBe("ready");
});
it("⭐ o pedido formal (fn_lgpd_cascade_redact_contact) chega ao mesmo estado", async () => {
await q("select public.fn_lgpd_cascade_redact_contact($1,$2,null)", [ORG, PELO_PEDIDO]);
expect(await transcricao(doPedido)).toBeNull();
});
it("⭐ a mensagem de OUTRO contato da mesma org não é tocada", async () => {
expect(await transcricao(doVizinho)).toBe(TRANSCRICAO);
});
it("⭐ a cura do apêndice alcança quem já era anonimizado e poupa quem voltou", async () => {
const baseline = readFileSync(join(process.cwd(), "supabase", "baseline.sql"), "utf8");
const bloco = baseline.slice(baseline.indexOf("(migration 0497) ----"));
const cura = /update public\.messages set\s+media_derived_text = null,[\s\S]*?where body = '\[mensagem anonimizada\]'[\s\S]*?;/.exec(
bloco.slice(0, bloco.indexOf("\n-- ---- ")),
)?.[0];
expect(cura, "a cura da 0497 não foi achada no apêndice do baseline").toBeTruthy();
await q(cura!);
expect(await transcricao(residuo)).toBeNull();
expect(await transcricao(deQuemVoltou)).toBe(TRANSCRICAO);
expect(await transcricao(doVizinho)).toBe(TRANSCRICAO);
});
it("⭐ todo UPDATE de messages que redige o body zera a transcrição, nos DOIS corpos instalados", async () => {
for (const funcao of ["fn_lgpd_cascade_redact_contact", "fn_redigir_conversas_ao_anonimizar"]) {
const { rows } = await q(
"select pg_get_functiondef(p.oid) def from pg_proc p where p.proname = $1 and p.pronamespace = 'public'::regnamespace",
[funcao],
);
expect(rows, `função ${funcao} não existe (ou existe em dobro) em public`).toHaveLength(1);
const blocos = [...(rows[0].def as string).matchAll(/update\s+(?:public\.)?messages\s+set([\s\S]*?)\bwhere\b/gi)].map(
(m) => m[1] ?? "",
);
expect(blocos.length, `nenhum UPDATE de messages em ${funcao} — a sonda ficou cega`).toBeGreaterThan(0);
for (const bloco of blocos) {
expect(bloco, `${funcao}: UPDATE de messages que redige o body e deixa a transcrição`).toContain(
"[mensagem anonimizada]",
);
expect(bloco, `${funcao}: UPDATE de messages sem media_derived_text = null`).toMatch(
/media_derived_text\s*=\s*null/,
);
}
}
});
});
@@ -0,0 +1,72 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import pg from "pg";
import { pgComoSupabase } from "../pg-como-supabase";
/**
* O ADAPTADOR NEGA `is` — `.not(col, "is", null)`, medido antes de medir.
*
* Nasceu pela cascata de LGPD (0497): `completarRedacaoDoContato` só apaga a
* transcrição da mensagem que AINDA a tem. Um `not(is)` que não filtrasse
* traria (e reescreveria) a tabela inteira, e a idempotência da varredura
* seria de mentira. Arquivo próprio porque `pg-como-supabase.test.ts` está sob
* o congelamento de `tests/invariants/**`; a régua é a mesma de lá.
*/
const container = process.env.TEST_DB_CONTAINER;
if (!container) {
throw new Error("TEST_DB_CONTAINER not set — rode via `pnpm test:db` (scripts/test-db.sh)");
}
const pool = new pg.Pool({
connectionString: `postgresql://postgres:postgres@127.0.0.1:${Number(process.env.TEST_DB_PORT ?? 54329)}/postgres`,
max: 2,
});
const db = pgComoSupabase(pool);
const ORG = "ada57e00-0000-4000-8000-000000000497";
beforeAll(async () => {
await pool.query(
`insert into organizations (id, slug, legal_name, display_name)
values ($1, 'org-adaptador-nega-is', 'Adaptador Nega Is', 'Adaptador Nega Is') on conflict (id) do nothing`,
[ORG],
);
// O trigger de seed criou o "Pedidos" (default). Estes são os extras: só
// "Alfa" tem descrição.
await pool.query(
`insert into crm_pipelines (organization_id, name, slug, is_default, position, description) values
($1, 'Alfa', 'alfa', false, 10, 'tem texto'),
($1, 'Bravo', 'bravo', false, 20, null),
($1, 'Zulu', 'zulu', false, 30, null)
on conflict do nothing`,
[ORG],
);
});
afterAll(async () => {
await pool.query("delete from organizations where id = $1", [ORG]);
await pool.end();
});
describe("o adaptador NEGA `is` — `.not(col, \"is\", null)`", () => {
it("na LEITURA traz só quem não é null", async () => {
const { data } = await db
.from("crm_pipelines")
.select("name")
.eq("organization_id", ORG)
.eq("is_default", false)
.not("description", "is", null);
expect((data as Array<{ name: string }>).map((x) => x.name)).toEqual(["Alfa"]);
});
it("na ESCRITA só alcança quem não é null", async () => {
const { data } = await db
.from("crm_pipelines")
.update({ description: "reescrita" })
.eq("organization_id", ORG)
.eq("is_default", false)
.not("description", "is", null)
.select("name");
expect((data as Array<{ name: string }>).map((x) => x.name)).toEqual(["Alfa"]);
});
});
+16
View File
@@ -214,6 +214,13 @@ class ConsultaPg<T> implements PromiseLike<RespostaFalsa<T[]>> {
this.filtros.push(["<>", coluna, valor]);
return this;
}
// `.not(col, "is", null)` — nasceu pela cascata de LGPD (0497), que só
// apaga a transcrição de mensagem que AINDA a tem. Um `is not` que não
// filtrasse faria a varredura parecer idempotente sem ser.
if (operador === "is") {
this.filtros.push(["is not", coluna, valor]);
return this;
}
return naoImplementado(`not(${operador})`);
}
@@ -292,6 +299,7 @@ class ConsultaPg<T> implements PromiseLike<RespostaFalsa<T[]>> {
const onde = this.filtros.map(([op, c, v]) => {
// `is` não gasta placeholder: a palavra entra inline.
if (op === "is") return `"${c}" is ${literalDeIs(v)}`;
if (op === "is not") return `"${c}" is not ${literalDeIs(v)}`;
valores.push(v);
// `= any` recebe o array inteiro num placeholder só; os demais operadores
// são infixos comuns.
@@ -463,6 +471,13 @@ class AtualizacaoPg<T> implements PromiseLike<RespostaFalsa<unknown>> {
this.filtros.push(["<>", coluna, valor]);
return this;
}
// `.not(col, "is", null)` — nasceu pela cascata de LGPD (0497), que só
// apaga a transcrição de mensagem que AINDA a tem. Um `is not` que não
// filtrasse faria a varredura parecer idempotente sem ser.
if (operador === "is") {
this.filtros.push(["is not", coluna, valor]);
return this;
}
return naoImplementado(`not(${operador})`);
}
@@ -481,6 +496,7 @@ class AtualizacaoPg<T> implements PromiseLike<RespostaFalsa<unknown>> {
const onde = this.filtros.map(([op, c, v]) => {
// `is` não gasta placeholder: a palavra entra inline.
if (op === "is") return `"${c}" is ${literalDeIs(v)}`;
if (op === "is not") return `"${c}" is not ${literalDeIs(v)}`;
valores.push(v);
if (op === "not = any") return `not ("${c}" = any($${valores.length}))`;
return `"${c}" ${op} $${valores.length}`;
+26
View File
@@ -216,6 +216,32 @@ describe("paraEventoDoGoogle", () => {
paraEventoDoGoogle(agendamento({ participantes: [{ email: " " }] })),
).toThrow(/participante sem e-mail/);
});
// ─── A CERCA (#1959) ────────────────────────────────────────────────────
// `AgendamentoParaGoogle` não tem `notes`, mas o `sync-executor.ts` chama esta
// função com `{ ...a, ... }` — o snapshot inteiro. Se o snapshot voltar a
// carregar `notes`, ele CHEGA aqui em tempo de execução, e o TypeScript não
// acusa nada (nem se os dois tipos ganharem o campo: medido). A cerca é de
// runtime. O caso sem observação é o que importa: foi exatamente o
// `a.description?.trim() || a.notes?.trim()` da #1959.
it.each([
["com observação visível", "Primeira consulta", "Primeira consulta"],
["sem observação (null)", null, undefined],
["com observação em branco", " ", undefined],
])("⚠️ nunca vaza a anotação INTERNA (notes) para o Google — %s", (_rotulo, description, esperada) => {
const comNotaInterna = {
...agendamento({ description }),
notes: "queixa clínica: paciente com ansiedade e dor torácica recorrente",
} as unknown as AgendamentoParaGoogle;
const corpo = paraEventoDoGoogle(comNotaInterna);
expect(corpo.description).toBe(esperada);
// Nenhum campo do corpo pode carregar o texto interno — pega também um
// espalhamento (`...a`) que criasse uma chave nova.
const serializa = JSON.stringify(corpo);
expect(serializa).not.toContain("queixa clínica");
expect(serializa).not.toContain("dor torácica");
expect("notes" in corpo).toBe(false);
});
});
// ─── VOLTA ─────────────────────────────────────────────────────────────────
@@ -122,3 +122,44 @@ describe("versionCreateSchema aceita as flags por-agente que a tela edita", () =
});
});
});
/**
* O SELECT e o schema iguais ainda não bastam: a server action da tela monta os
* INSERTs de versão campo a campo. `inbound_debounce_ms` (#1856) entrou só no
* PATCH de rascunho existente e ficou fora dos três INSERTs — salvar um agente
* publicado, reverter pelo Histórico e criar pela tela gravavam NULL, e a janela
* voltava calada para a env. Este bloco lê cada `.insert({ ... })` encadeado em
* `.from("ai_agent_versions")` da action e cobra a coluna em todos.
*/
function insertsDeVersao(source: string): string[] {
const blocos: string[] = [];
const re = /\.from\("ai_agent_versions"\)\s*\.insert\(\{/g;
for (let m = re.exec(source); m !== null; m = re.exec(source)) {
let i = m.index + m[0].length;
let nivel = 1;
for (; i < source.length && nivel > 0; i++) {
if (source[i] === "{") nivel++;
else if (source[i] === "}") nivel--;
}
blocos.push(source.slice(m.index, i));
}
return blocos;
}
describe("INSERTs de versão da tela levam a janela de rajada", () => {
it("o extrator acusa um INSERT sem a coluna (controle positivo)", () => {
const sem = 'admin.from("ai_agent_versions").insert({ split_max_chars: 1, followup: { a: 1 } })';
const blocos = insertsDeVersao(sem);
expect(blocos).toHaveLength(1);
expect(blocos[0]).toContain("split_max_chars");
expect(blocos[0]).not.toContain("inbound_debounce_ms");
});
it("todo INSERT de ai_agent_versions em _actions.ts grava inbound_debounce_ms", () => {
const source = readFileSync(join(ROOT, "app/app/ai/agents/[id]/_actions.ts"), "utf8");
const blocos = insertsDeVersao(source);
expect(blocos.length).toBeGreaterThan(0);
const semColuna = blocos.filter((b) => !b.includes("inbound_debounce_ms"));
expect(semColuna.map((b) => b.slice(0, 200))).toEqual([]);
});
});
+107
View File
@@ -0,0 +1,107 @@
import { describe, expect, it } from "vitest";
import {
ATRASO_MAXIMO_MS,
ATRASO_MINIMO_MS,
ATRASO_NOTAR_MS,
MS_POR_CARACTERE,
atrasoHumanoEfetivo,
calcularAtrasoHumano,
} from "@/lib/agent-engine/agent/atraso-humano";
import { PACING_DEFAULTS } from "@/lib/agent-engine/pacing/defaults";
import { effectiveKnobs } from "@/lib/ai/pacing-knobs";
/**
* Issue #653 — o atraso humano antes da 1ª bolha vira knob por conexão.
*
* Os quatro números (`atraso_humano` NOTAR/POR_CARACTERE/MINIMO/MAXIMO) eram
* literais em `atraso-humano.ts`. Agora entram pelo `channel_knobs` (0499), com
* DEFAULT = os valores de antes: quem nunca configurou tem regressão ZERO, e o
* leitor (`loadChannelKnobs`/`effectiveKnobs`) preenche os defaults.
*/
describe("atraso humano: default idêntico ao código de antes (regressão zero)", () => {
it("os defaults do módulo espelham os valores históricos", () => {
expect(PACING_DEFAULTS.atrasoNotarMs).toBe(ATRASO_NOTAR_MS); // 900
expect(PACING_DEFAULTS.msPorCaractere).toBe(MS_POR_CARACTERE); // 22
expect(PACING_DEFAULTS.atrasoMinimoMs).toBe(ATRASO_MINIMO_MS); // 1200
expect(PACING_DEFAULTS.atrasoMaximoMs).toBe(ATRASO_MAXIMO_MS); // 7500
});
it("sem knobs, calcularAtrasoHumano devolve exatamente o histórico (piso e teto)", () => {
// O mesmo texto curto que o teste antigo prendia: cai no PISO.
expect(calcularAtrasoHumano("Oi, tudo bem?")).toBe(ATRASO_MINIMO_MS);
// Parágrafo longo cai no TETO.
expect(calcularAtrasoHumano("a".repeat(4000))).toBe(ATRASO_MAXIMO_MS);
// Vazio >= piso.
expect(calcularAtrasoHumano("")).toBe(ATRASO_MINIMO_MS);
});
it("atrasoHumanoEfetivo preenche todos os quatro com o default quando ausente", () => {
expect(atrasoHumanoEfetivo(undefined)).toEqual({
atrasoNotarMs: ATRASO_NOTAR_MS,
msPorCaractere: MS_POR_CARACTERE,
atrasoMinimoMs: ATRASO_MINIMO_MS,
atrasoMaximoMs: ATRASO_MAXIMO_MS,
});
// default parcial: o não-informado cai no default.
expect(atrasoHumanoEfetivo({ msPorCaractere: 10 })).toMatchObject({ msPorCaractere: 10 });
expect(atrasoHumanoEfetivo({ msPorCaractere: 10 }).atrasoNotarMs).toBe(ATRASO_NOTAR_MS);
});
});
describe("atraso humano: knobs por conexão mudam o resultado", () => {
it("NOTAR maior empurra o atraso para cima (mesmo texto)", () => {
const texto = "Temos sim.";
const base = calcularAtrasoHumano(texto);
const comNotar = calcularAtrasoHumano(texto, { atrasoNotarMs: base + 500, atrasoMaximoMs: 10_000 });
expect(comNotar).toBeGreaterThan(base);
});
it("POR_CARACTERE maior pentencia em textos maiores", () => {
const curto = calcularAtrasoHumano("Oi", { msPorCaractere: 50, atrasoMaximoMs: 10_000 });
const longo = calcularAtrasoHumano("a".repeat(40), { msPorCaractere: 50, atrasoMaximoMs: 10_000 });
expect(longo).toBeGreaterThan(curto);
});
it("teto da conexão é respeitado (não o default 7500)", () => {
// Texto que no default explodiria no teto de 7500; com teto menor, para nele.
// bruto = 900 + 22*400 = 9700 (> 7500 nos dois casos).
expect(calcularAtrasoHumano("a".repeat(400), { atrasoMaximoMs: 3000 })).toBe(3000);
expect(calcularAtrasoHumano("a".repeat(400))).toBe(ATRASO_MAXIMO_MS);
});
it("piso da conexão pode ser menor que o piso padrão sem afrouxar anti-ban", () => {
// "" só ultrapassa o piso 1200 quando NOTAR > 1200; com NOTAR 50 o bruto
// (50) fica abaixo do piso 200 da conexão, que o leva para cima.
expect(
calcularAtrasoHumano("", { atrasoNotarMs: 50, atrasoMinimoMs: 200, atrasoMaximoMs: 5000 }),
).toBe(200);
// Sem knobs, o "" recai no piso padrão 1200 (regressão zero).
expect(calcularAtrasoHumano("")).toBe(ATRASO_MINIMO_MS);
});
});
describe("leitor efetivo: linha de channel_knobs sobre os defaults", () => {
it("sem linha, effectiveKnobs devolve os defaults", () => {
const eff = effectiveKnobs(null);
expect(eff.atrasoNotarMs).toBe(PACING_DEFAULTS.atrasoNotarMs);
expect(eff.msPorCaractere).toBe(PACING_DEFAULTS.msPorCaractere);
expect(eff.atrasoMinimoMs).toBe(PACING_DEFAULTS.atrasoMinimoMs);
expect(eff.atrasoMaximoMs).toBe(PACING_DEFAULTS.atrasoMaximoMs);
});
it("com linha, effectiveKnobs devolve o valor da conexão (e null volta ao default)", () => {
const eff = effectiveKnobs(
{
atraso_notar_ms: 1200,
ms_por_caractere: null,
atraso_minimo_ms: 1500,
atraso_maximo_ms: 6000,
} as never,
);
expect(eff.atrasoNotarMs).toBe(1200);
expect(eff.msPorCaractere).toBe(PACING_DEFAULTS.msPorCaractere); // null -> default
expect(eff.atrasoMinimoMs).toBe(1500);
expect(eff.atrasoMaximoMs).toBe(6000);
});
});
@@ -162,10 +162,15 @@ describe("fiação — a espera humana é paga UMA vez por turno", () => {
expect(doSendMessage).toBeGreaterThan(-1);
const i = FONTE_INBOUND.indexOf("send: (finalBody: string) =>", doSendMessage);
expect(i).toBeGreaterThan(-1);
const janela = FONTE_INBOUND.slice(i, i + 1600);
const janela = FONTE_INBOUND.slice(i, i + 2400);
// Os dois convivem: o jitter é throttle anti-ban entre mensagens físicas, o
// atraso humano é a pausa do turno. Perder o primeiro é afrouxar o anti-ban.
expect(janela).toMatch(/jitter\s*[:=]\s*\(\)\s*=>\s*1200 \+ Math\.floor\(Math\.random\(\) \* 800\)/);
// Desde a 0499 o jitter LÊ os knobs da conexão (throttleMs + jitterMaxMs,
// defaults 1200/800) em vez do literal `1200 + rand*800` — mesma proteção,
// agora configurável por número.
expect(janela).toMatch(/const jitter = \(\) =>/);
expect(janela).toMatch(/pacingDoTurno\?\.knobs\.throttleMs \?\? 1200/);
expect(janela).toMatch(/Math\.floor\(Math\.random\(\) \* \(pacingDoTurno\?\.knobs\.jitterMaxMs \?\? 800\)\)/);
expect(janela).toContain("sendInBubbles(texto, {");
// Issue #654: a pausa humana saiu daqui. Ela era paga no `antesDaPrimeira`, que
// rodava dentro do callback `send` — isto é, com o `pg_advisory_xact_lock` do
@@ -95,6 +95,7 @@ function db(
return q;
},
in: () => q,
not: () => q,
limit: () => q,
maybeSingle: async () => ({ data: contato, error: null }),
then: (r: (v: { data: unknown; error: null }) => unknown) => {
@@ -124,6 +124,9 @@ function banco(linhas: Linha[]) {
dentro = [col, vals];
return q;
},
// `.not(col, "is", null)` do passo da transcrição (0497): este arquivo
// não planta mensagem, então a tabela nunca casa e o filtro só precisa existir.
not: () => q,
limit: (n: number) => {
teto = n;
return q;
@@ -33,6 +33,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import {
MAX_CONTATOS_EXAMINADOS,
MAX_CONTATOS_POR_VARREDURA,
MENSAGEM_REDIGIDA,
NOTA_REDIGIDA,
SUFIXO_ANONIMIZADO,
type ClienteDaCascata,
@@ -92,7 +93,7 @@ interface Linha {
payload?: unknown;
is_anonymized?: boolean;
headline?: string;
body?: string;
body?: string | null;
tool_calls?: unknown;
social_identity?: unknown;
next_action?: string | null;
@@ -103,6 +104,7 @@ interface Linha {
completed_at?: string | null;
cancel_reason?: string | null;
embedding?: unknown;
media_derived_text?: string | null;
}
interface Escrita {
@@ -133,6 +135,7 @@ function banco(linhas: Linha[]) {
filtros: Array<[string, unknown]>,
dentro: [string, string[]] | null,
faixas: Array<[string, string]> = [],
naoNulos: string[] = [],
): Linha[] =>
linhas.filter((l) => {
if ((l.id.split(":")[0] ?? "") !== tabela) return false;
@@ -145,6 +148,12 @@ function banco(linhas: Linha[]) {
const atual = (l as unknown as Record<string, string | undefined>)[col];
if (atual === undefined || !(atual < val)) return false;
}
// `.not(col, "is", null)` do passo da transcrição (0497). Ignorá-lo
// faria a mensagem já limpa voltar como resíduo e mascararia a idempotência.
for (const col of naoNulos) {
const atual = (l as unknown as Record<string, unknown>)[col];
if (atual === null || atual === undefined) return false;
}
// O `.in()` da cascata vem em DUAS colunas — `id` no UPDATE das
// atividades, `contact_id` na detecção em bloco. Um dublê que
// ignorasse a coluna casaria as duas na errada e daria verde falso.
@@ -162,6 +171,7 @@ function banco(linhas: Linha[]) {
) => {
const filtros: Array<[string, unknown]> = [];
const faixas: Array<[string, string]> = [];
const naoNulos: string[] = [];
let dentro: [string, string[]] | null = null;
let teto: number | null = null;
const q: Record<string, unknown> = {
@@ -177,6 +187,10 @@ function banco(linhas: Linha[]) {
dentro = [col, vals];
return q;
},
not: (col: string) => {
naoNulos.push(col);
return q;
},
select: () => q,
order: () => q,
limit: (n: number) => {
@@ -184,7 +198,7 @@ function banco(linhas: Linha[]) {
return q;
},
then: (r: (v: unknown) => unknown) => {
let achadas = casar(filtros, dentro, faixas);
let achadas = casar(filtros, dentro, faixas, naoNulos);
if (teto !== null) achadas = achadas.slice(0, teto);
if (modo === "update") {
aplicar(tabela, patch, achadas);
@@ -386,6 +400,7 @@ describe("a unidade que as duas bocas compartilham", () => {
runsRedigidas: 0,
estadosRedigidos: 0,
socialIdentidadeRedigida: false,
transcricoesRedigidas: 0,
tabelas: [],
falhas: [],
};
@@ -396,6 +411,7 @@ describe("a unidade que as duas bocas compartilham", () => {
expect(houveRedacao({ ...vazio, runsRedigidas: 1 })).toBe(true);
expect(houveRedacao({ ...vazio, estadosRedigidos: 1 })).toBe(true);
expect(houveRedacao({ ...vazio, socialIdentidadeRedigida: true })).toBe(true);
expect(houveRedacao({ ...vazio, transcricoesRedigidas: 1 })).toBe(true);
});
it("⭐ `tabelas` lista só o que foi tocado — não o literal das três", async () => {
@@ -535,6 +551,73 @@ describe("a unidade que as duas bocas compartilham", () => {
});
});
describe("a transcrição da mídia sai junto (0497, triagem do #1988)", () => {
// O banco redige o body da mensagem na virada de is_anonymized; a transcrição
// (`media_derived_text`) ficava. A varredura é quem alcança a que o
// `media-derive-worker` grava DEPOIS da anonimização — nenhum gatilho dispara ali.
const audio = (sufixo: string, over: Partial<Linha> = {}): Linha => ({
id: `messages:${sufixo}`,
organization_id: ORG,
contact_id: "contacts:a",
body: MENSAGEM_REDIGIDA,
media_derived_text: "oi, aqui é a Maria, meu CPF é 123",
...over,
});
it("⭐ mensagem anonimizada que guardou a transcrição: a varredura apaga", async () => {
alvo = banco([contatoAnonimizado(), audio("1")]);
const r = await varrerRedacoesIncompletas(alvo.cliente);
expect(r.completados).toHaveLength(1);
expect(r.completados[0]!.resultado.transcricoesRedigidas).toBe(1);
expect(r.completados[0]!.resultado.tabelas).toContain("messages:media_derived_text");
expect(alvo.linhas.find((l) => l.id === "messages:1")!.media_derived_text).toBeNull();
});
it("⭐ poupa quem voltou a escrever, o vizinho e a outra org", async () => {
alvo = banco([
contatoAnonimizado(),
// body de verdade: mensagem NOVA de quem voltou (religado pelo LID).
audio("nova", { body: null, media_derived_text: "voltei, quero o orçamento" }),
// mesmo body-sentinela, mas de OUTRO contato da mesma org, não anonimizado.
audio("vizinho", { contact_id: "contacts:b" }),
// mesmo contact_id em OUTRA org — só existe se algo já vazou.
audio("outra-org", { organization_id: OUTRA_ORG }),
]);
const r = await varrerRedacoesIncompletas(alvo.cliente);
expect(r.completados).toEqual([]);
expect(alvo.linhas.find((l) => l.id === "messages:nova")!.media_derived_text).toBe("voltei, quero o orçamento");
expect(alvo.linhas.find((l) => l.id === "messages:vizinho")!.media_derived_text).not.toBeNull();
expect(alvo.linhas.find((l) => l.id === "messages:outra-org")!.media_derived_text).not.toBeNull();
});
it("⭐ idempotente: a segunda passada não escreve nada", async () => {
alvo = banco([contatoAnonimizado(), audio("1"), audio("2")]);
const primeira = await varrerRedacoesIncompletas(alvo.cliente);
expect(primeira.completados[0]!.resultado.transcricoesRedigidas).toBe(2);
const escritas = alvo.escritas.length;
const segunda = await varrerRedacoesIncompletas(alvo.cliente);
expect(segunda.completados).toEqual([]);
expect(alvo.escritas.length).toBe(escritas);
});
it("a rota (completarRedacaoDoContato) apaga pelo mesmo passo", async () => {
alvo = banco([contatoAnonimizado(), audio("1")]);
const r = await completarRedacaoDoContato(alvo.cliente, { id: "contacts:a", organizationId: ORG });
expect(r.transcricoesRedigidas).toBe(1);
expect(houveRedacao(r)).toBe(true);
expect(alvo.linhas.find((l) => l.id === "messages:1")!.media_derived_text).toBeNull();
});
});
describe("o laço de retorno: a varredura deixa rastro por contato", () => {
function requisicaoAutorizada() {
return { headers: new Headers({ authorization: "Bearer segredo" }) } as never;
@@ -88,3 +88,14 @@ describe("todo montador de ferramentas do turno passa o escopo", () => {
).toEqual([]);
});
});
describe("o espelho de etapa recebe o escopo do agente publicado", () => {
it("o turno não omite os funis ao chamar mirrorLeadStageToCrm", () => {
const fonte = readFileSync(resolve(RAIZ, "lib/agent-engine/agent/inbound-turn.ts"), "utf8");
const chamada = fonte.match(/const mirror = await mirrorLeadStageToCrm\([\s\S]*?\n\s*\}\);/);
expect(chamada, "o turno não chama o espelho do CRM").not.toBeNull();
expect(chamada?.[0], "o espelho ignoraria os funis autorizados na versão publicada").toMatch(
/pipelineIds: agentConfig\.pipelineIds/,
);
});
});
@@ -84,6 +84,10 @@ function canal(over: Partial<ChannelSession> = {}): ChannelSession {
const KNOBS: PacingKnobs = {
throttleMs: 1_200,
jitterMaxMs: 800,
atrasoNotarMs: 900,
msPorCaractere: 22,
atrasoMinimoMs: 1_200,
atrasoMaximoMs: 7_500,
windowStartHour: 7,
windowEndHour: 22,
respostaStartHour: 7,
@@ -113,6 +117,8 @@ function itemDePacing(over: Partial<PacingKnobsItem> = {}): PacingKnobsItem {
defaults: KNOBS,
bounds: {
intervalMaxMs: 600_000,
msPorCaractereMax: 200,
atrasoMaximoMsMax: 60_000,
hourLastStart: 23,
hourEnd: 24,
daily_limit: { min: 1, max: 2_000 },
@@ -319,6 +319,40 @@ describe("PUT /api/v1/ai/pacing — janela de resposta (0495)", () => {
});
});
describe("PUT /api/v1/ai/pacing — atraso humano (0499): mínimo acima do máximo é 422, não 500", () => {
it("mínimo sozinho acima do teto padrão (7500) é recusado, em vez de ignorado pelo clamp", async () => {
authOk();
const db = makeDb(null);
const { PUT } = await import("@/app/api/v1/ai/pacing/route");
const res = await PUT(put(corpoDaTela({ atraso_minimo_ms: 9000 })));
expect(res.status).toBe(422);
expect(db.upserts).toHaveLength(0);
});
it("mínimo e máximo gravados invertidos são recusados antes do CHECK do banco", async () => {
authOk();
const db = makeDb(null);
const { PUT } = await import("@/app/api/v1/ai/pacing/route");
const res = await PUT(put(corpoDaTela({ atraso_minimo_ms: 5000, atraso_maximo_ms: 3000 })));
const corpo = await res.json();
expect(res.status).toBe(422);
expect(corpo.error.message).toContain("5000");
expect(db.upserts).toHaveLength(0);
});
it("par coerente continua sendo gravado (a guarda não virou bloqueio)", async () => {
authOk();
const db = makeDb(null);
const { PUT } = await import("@/app/api/v1/ai/pacing/route");
const res = await PUT(put(corpoDaTela({ atraso_minimo_ms: 3000, atraso_maximo_ms: 3000 })));
expect(res.status).toBe(200);
expect(db.knobs).toMatchObject({ atraso_minimo_ms: 3000, atraso_maximo_ms: 3000 });
});
});
describe("A TELA e o texto que ela promete", () => {
it("o texto de ajuda não promete rejuvenescer um número cuja data já está salva", () => {
const sheet = readFileSync(