feat(secretaria): follow-up proativo com atraso (Parte B)

Quando o cliente insiste num horário mais tarde, a IA usa confirmar_depois:
diz "vou confirmar, aguarde uns minutinhos" e registra um follow-up
(sec_agenda_followups, fire_at = now + followup_delay_min, default 15). Um worker
no ext-api (a cada 30s) processa os vencidos: consulta /slots perto do horário
desejado e manda ao paciente "Oie, consegui! Posso às X?" (assinado pelo agente).

Testado em dev: worker processa follow-up vencido, consulta a agenda e envia.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Deploy Agent
2026-07-06 06:15:26 +02:00
parent 163a5d0db2
commit 02a952c60b
12 changed files with 226 additions and 4 deletions
@@ -354,6 +354,53 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
const secAgent = await db('sec_agents').where({ id: conv.agent_id }).select('name').first();
await sendSecretariaReply({ instanceId, tenantId, chatId, jid, reply, agentName: secAgent?.name });
});
// ── Worker: follow-ups proativos da agenda (Parte B) ──────────────────────
// A cada 30s processa os follow-ups vencidos (confirmar_depois): consulta a
// agenda perto do horário desejado e manda ao paciente "Oie, consegui!".
setInterval(async () => {
let due = [];
try {
due = await db('sec_agenda_followups').where('status', 'pendente').where('fire_at', '<=', new Date()).limit(10);
}
catch {
return;
}
for (const fu of due) {
try {
const claimed = await db('sec_agenda_followups').where({ id: fu.id, status: 'pendente' }).update({ status: 'processando' });
if (!claimed)
continue;
const dataStr = fu.data ? (fu.data instanceof Date ? fu.data.toISOString().slice(0, 10) : String(fu.data).slice(0, 10)) : null;
const u = new URL(`${String(fu.agenda_url).replace(/\/$/, '')}/slots`);
u.searchParams.set('clinica_id', fu.clinica_id);
if (dataStr)
u.searchParams.set('date', dataStr);
if (fu.periodo)
u.searchParams.set('periodo', fu.periodo);
const r = await fetch(u, { headers: { 'x-nw-agenda-secret': fu.agenda_secret || '' }, signal: AbortSignal.timeout(12000) });
const j = r.ok ? await r.json() : {};
const slots = j?.slots ?? [];
// slot mais próximo do horário desejado (senão o primeiro).
let escolhido = slots[0];
if (fu.hora_desejada && slots.length) {
const alvo = String(fu.hora_desejada).slice(0, 5);
escolhido = slots.slice().sort((a, b) => Math.abs(a.start.slice(11, 16).localeCompare(alvo)) - Math.abs(b.start.slice(11, 16).localeCompare(alvo)))[0] || slots[0];
}
const texto = escolhido
? `Oie! Consegui aqui 😊 Posso te agendar${dataStr ? ` dia ${dataStr.slice(8, 10)}/${dataStr.slice(5, 7)}` : ''} às ${escolhido.start.slice(11, 16)}. Fica bom pra você?`
: 'Oi! Verifiquei a agenda e não consegui esse horário 😕 Posso te oferecer outro dia ou período?';
// assina com o agente do número (channel) para manter o padrão.
const ch = await db('sec_numbers').where({ instance_id: fu.instance_id }).first();
const ag = ch?.agent_id ? await db('sec_agents').where({ id: ch.agent_id }).first() : await db('sec_agents').where({ active: true }).orderBy('created_at').first();
const chat = await prisma.chat.findFirst({ where: { instanceId: fu.instance_id, jid: fu.jid }, orderBy: { updatedAt: 'desc' }, select: { id: true } });
await sendSecretariaReply({ instanceId: fu.instance_id, tenantId: fu.tenant_id, chatId: chat?.id ?? fu.jid, jid: fu.jid, reply: texto, agentName: ag?.name });
await db('sec_agenda_followups').where({ id: fu.id }).update({ status: 'enviado' });
}
catch {
await db('sec_agenda_followups').where({ id: fu.id }).update({ status: 'falhou' }).catch(() => { });
}
}
}, 30000);
// ── Escalation notification listener ──────────────────────────────────────
// When escalar_humano tool fires, send a WA message to the configured admin phone.
hooks.register('ext:escalated', async (data) => {