feat(secretaria): encaixe sub-hora escala ao dentista + auto-confirma (Parte A)

Encaixe que quebra o espaçamento de 1h (ex.: 09:45): a IA usa perguntar_dentista
→ resolve o WhatsApp do dentista (bridge /dentista-contato), registra
sec_agenda_approvals (aguardando) e manda a pergunta ao dentista ("consegue
encaixar às X? SIM/NÃO", via hook ext:agenda.notify-dentist). Quando o dentista
responde, ext:message.new detecta (roda mesmo com auto_reply off), interpreta
sim/não; se SIM agenda (/book) e avisa o paciente automaticamente ("o Dr
confirmou, agendei às X"); se NÃO oferece outro horário.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Deploy Agent
2026-07-06 06:31:40 +02:00
parent 02a952c60b
commit f9e93647bb
12 changed files with 263 additions and 4 deletions
@@ -297,6 +297,47 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
// newsletter, lid e status — a Secretária não cria conversa para esses.
if (!jid.endsWith('@s.whatsapp.net'))
return;
// ── Parte A: é a RESPOSTA do DENTISTA a um pedido de encaixe (SIM/NÃO)? ──
// Roda mesmo com auto_reply off (é fluxo de sistema). Se sim, agenda/avisa o
// paciente e NÃO segue o fluxo normal da Secretária para esta mensagem.
try {
const approval = await db('sec_agenda_approvals').where({ dentista_jid: jid, status: 'aguardando' }).orderBy('created_at', 'desc').first();
if (approval) {
const low = String(text).toLowerCase().trim();
const disseSim = /(^|\W)(sim|pode|consigo|d[áa]\s*tempo|ok|claro|beleza|confirmo|isso|fechou|manda|pos)/.test(low) || low.includes('👍');
const disseNao = /(^|\W)(n[ãa]o|nao)/.test(low);
const sinalOn = async (instId, j) => (await prisma.chat.findFirst({ where: { instanceId: instId, jid: j }, orderBy: { updatedAt: 'desc' }, select: { id: true } }))?.id;
const chnl = await db('sec_numbers').where({ instance_id: approval.instance_id }).first();
const ag = chnl?.agent_id ? await db('sec_agents').where({ id: chnl.agent_id }).first() : await db('sec_agents').where({ active: true }).orderBy('created_at').first();
const dataStr = approval.data instanceof Date ? approval.data.toISOString().slice(0, 10) : String(approval.data).slice(0, 10);
const horaStr = String(approval.hora).slice(0, 5);
if (disseSim && !disseNao) {
let confirmado = false;
try {
const r = await fetch(`${String(approval.agenda_url).replace(/\/$/, '')}/book`, {
method: 'POST', headers: { 'Content-Type': 'application/json', 'x-nw-agenda-secret': approval.agenda_secret || '' }, signal: AbortSignal.timeout(12000),
body: JSON.stringify({ clinica_id: approval.clinica_id, dentista_id: approval.dentista_id, start: `${dataStr} ${horaStr}:00`, paciente_nome: approval.paciente_nome, paciente_celular: approval.paciente_telefone, procedimento: approval.procedimento }),
});
confirmado = r.ok;
}
catch { /* falha de rede */ }
await db('sec_agenda_approvals').where({ id: approval.id }).update({ status: confirmado ? 'confirmado' : 'recusado', updated_at: new Date() });
const pmsg = confirmado
? `Oie! O Dr(a) ${String(approval.dentista_nome || '').split(' ')[0]} confirmou 🎉 Agendei você para ${dataStr.slice(8, 10)}/${dataStr.slice(5, 7)} às ${horaStr}. Te espero!`
: `Oie! Consegui o ok do dentista, mas tive um probleminha pra registrar o horário — já te retorno pra fechar, tá?`;
await sendSecretariaReply({ instanceId: approval.instance_id, tenantId: approval.tenant_id, chatId: (await sinalOn(approval.instance_id, approval.paciente_jid)) ?? approval.paciente_jid, jid: approval.paciente_jid, reply: pmsg, agentName: ag?.name });
await sendSecretariaReply({ instanceId, tenantId, chatId: (await sinalOn(instanceId, jid)) ?? jid, jid, reply: 'Perfeito, obrigado! Já confirmei com o paciente. 🙌' });
return;
}
if (disseNao) {
await db('sec_agenda_approvals').where({ id: approval.id }).update({ status: 'recusado', updated_at: new Date() });
await sendSecretariaReply({ instanceId: approval.instance_id, tenantId: approval.tenant_id, chatId: (await sinalOn(approval.instance_id, approval.paciente_jid)) ?? approval.paciente_jid, jid: approval.paciente_jid, reply: 'Oie! Infelizmente não deu pra encaixar nesse horário 😕 Mas posso te oferecer outro — prefere de manhã ou à tarde?', agentName: ag?.name });
return;
}
return; // resposta do dentista ainda ambígua — aguarda um SIM/NÃO claro
}
}
catch { /* segue o fluxo normal */ }
// Kill-switch do auto-reply: secretaria.auto_reply === false desliga SÓ o
// disparo automático da Secretária (envios manuais seguem normais). Só
// bloqueia quando explicitamente false — sem a flag, comportamento inalterado.
@@ -401,6 +442,17 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
}
}
}, 30000);
// ── Parte A: envia a pergunta de encaixe ao DENTISTA (tool perguntar_dentista) ─
hooks.register('ext:agenda.notify-dentist', async (data) => {
const { instanceId, tenantId, jid, text } = data || {};
if (!instanceId || !jid || !text)
return;
try {
const chat = await prisma.chat.findFirst({ where: { instanceId, jid }, orderBy: { updatedAt: 'desc' }, select: { id: true } });
await sendSecretariaReply({ instanceId, tenantId, chatId: chat?.id ?? jid, jid, reply: text });
}
catch { /* best-effort */ }
});
// ── Escalation notification listener ──────────────────────────────────────
// When escalar_humano tool fires, send a WA message to the configured admin phone.
hooks.register('ext:escalated', async (data) => {