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:
@@ -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) => {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -277,6 +277,46 @@ export function buildExtRoutes(
|
||||
// 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: string, j: string) => (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(12_000),
|
||||
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.
|
||||
@@ -380,6 +420,16 @@ export function buildExtRoutes(
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
// ── Parte A: envia a pergunta de encaixe ao DENTISTA (tool perguntar_dentista) ─
|
||||
hooks.register('ext:agenda.notify-dentist', async (data: any) => {
|
||||
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: any) => {
|
||||
|
||||
@@ -217,6 +217,7 @@ class ProtocolEngine {
|
||||
L.push('3) Se não servir, ofereça o PRÓXIMO horário livre, um de cada vez (nunca uma lista). Mantenha sempre 1 hora entre os atendimentos.');
|
||||
L.push('3b) Se o cliente insistir num horário MAIS TARDE no dia, use a ferramenta confirmar_depois: diga que vai confirmar a agenda e para aguardar uns minutinhos — o sistema retorna sozinho com o horário. NÃO ofereça o horário na hora.');
|
||||
L.push('4) Se o cliente pedir um horário PONTUAL fora dos disponíveis (mais cedo/mais tarde, ex.: 8h30 ou depois das 17h), NÃO recuse nem confirme: use a ferramenta solicitar_horario_especial e responda algo como "No momento esse horário não está disponível, mas ainda não confirmei a agenda — posso te confirmar daqui a pouco?" (sem mencionar "secretária").');
|
||||
L.push('5) Se o cliente pedir um ENCAIXE que QUEBRA o espaçamento de 1h (horário "quebrado", ex.: 09:45, 10:20, ou uma avaliação rápida entre atendimentos), use a ferramenta perguntar_dentista: ela pergunta ao dentista responsável se dá tempo. Diga ao cliente que vai verificar com o dentista e confirma em seguida.');
|
||||
return `\n\n${L.join('\n')}\n`;
|
||||
}
|
||||
// ── System Prompt Builder ─────────────────────────────────────────────────
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -245,6 +245,7 @@ export class ProtocolEngine {
|
||||
L.push('3) Se não servir, ofereça o PRÓXIMO horário livre, um de cada vez (nunca uma lista). Mantenha sempre 1 hora entre os atendimentos.')
|
||||
L.push('3b) Se o cliente insistir num horário MAIS TARDE no dia, use a ferramenta confirmar_depois: diga que vai confirmar a agenda e para aguardar uns minutinhos — o sistema retorna sozinho com o horário. NÃO ofereça o horário na hora.')
|
||||
L.push('4) Se o cliente pedir um horário PONTUAL fora dos disponíveis (mais cedo/mais tarde, ex.: 8h30 ou depois das 17h), NÃO recuse nem confirme: use a ferramenta solicitar_horario_especial e responda algo como "No momento esse horário não está disponível, mas ainda não confirmei a agenda — posso te confirmar daqui a pouco?" (sem mencionar "secretária").')
|
||||
L.push('5) Se o cliente pedir um ENCAIXE que QUEBRA o espaçamento de 1h (horário "quebrado", ex.: 09:45, 10:20, ou uma avaliação rápida entre atendimentos), use a ferramenta perguntar_dentista: ela pergunta ao dentista responsável se dá tempo. Diga ao cliente que vai verificar com o dentista e confirma em seguida.')
|
||||
return `\n\n${L.join('\n')}\n`
|
||||
}
|
||||
|
||||
|
||||
@@ -218,6 +218,32 @@ async function runMigrations(db) {
|
||||
});
|
||||
await db.raw('CREATE INDEX IF NOT EXISTS idx_followups_fire ON sec_agenda_followups (status, fire_at)');
|
||||
}
|
||||
// ── sec_agenda_approvals — Parte A: encaixe sub-hora aguardando o DENTISTA.
|
||||
// A IA manda WhatsApp ao dentista; quando ele responde SIM/NÃO, o sistema
|
||||
// agenda (ou não) e avisa o paciente automaticamente.
|
||||
if (!(await db.schema.hasTable('sec_agenda_approvals'))) {
|
||||
await db.schema.createTable('sec_agenda_approvals', (t) => {
|
||||
t.uuid('id').primary().defaultTo(db.raw('gen_random_uuid()'));
|
||||
t.string('tenant_id', 80);
|
||||
t.string('instance_id', 100);
|
||||
t.string('clinica_id', 100);
|
||||
t.string('agenda_url', 300);
|
||||
t.string('agenda_secret', 200);
|
||||
t.string('paciente_jid', 120);
|
||||
t.string('paciente_chat_id', 300);
|
||||
t.string('paciente_nome', 120);
|
||||
t.string('paciente_telefone', 30);
|
||||
t.string('dentista_id', 120);
|
||||
t.string('dentista_nome', 120);
|
||||
t.string('dentista_jid', 120); // WhatsApp do dentista
|
||||
t.date('data');
|
||||
t.string('hora', 8);
|
||||
t.string('procedimento', 160);
|
||||
t.string('status', 20).defaultTo('aguardando'); // aguardando | confirmado | recusado
|
||||
t.timestamps(true, true);
|
||||
});
|
||||
await db.raw('CREATE INDEX IF NOT EXISTS idx_approvals_dent ON sec_agenda_approvals (dentista_jid, status)');
|
||||
}
|
||||
// ── Seeds: agente padrão + nós + calendário ───────────────────────────────
|
||||
const agentCount = await db('sec_agents').count('id as c').first();
|
||||
if (Number(agentCount?.c ?? 0) === 0) {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -232,6 +232,33 @@ export async function runMigrations(db: Knex): Promise<void> {
|
||||
await db.raw('CREATE INDEX IF NOT EXISTS idx_followups_fire ON sec_agenda_followups (status, fire_at)')
|
||||
}
|
||||
|
||||
// ── sec_agenda_approvals — Parte A: encaixe sub-hora aguardando o DENTISTA.
|
||||
// A IA manda WhatsApp ao dentista; quando ele responde SIM/NÃO, o sistema
|
||||
// agenda (ou não) e avisa o paciente automaticamente.
|
||||
if (!(await db.schema.hasTable('sec_agenda_approvals'))) {
|
||||
await db.schema.createTable('sec_agenda_approvals', (t) => {
|
||||
t.uuid('id').primary().defaultTo(db.raw('gen_random_uuid()'))
|
||||
t.string('tenant_id', 80)
|
||||
t.string('instance_id', 100)
|
||||
t.string('clinica_id', 100)
|
||||
t.string('agenda_url', 300)
|
||||
t.string('agenda_secret', 200)
|
||||
t.string('paciente_jid', 120)
|
||||
t.string('paciente_chat_id', 300)
|
||||
t.string('paciente_nome', 120)
|
||||
t.string('paciente_telefone', 30)
|
||||
t.string('dentista_id', 120)
|
||||
t.string('dentista_nome', 120)
|
||||
t.string('dentista_jid', 120) // WhatsApp do dentista
|
||||
t.date('data')
|
||||
t.string('hora', 8)
|
||||
t.string('procedimento', 160)
|
||||
t.string('status', 20).defaultTo('aguardando') // aguardando | confirmado | recusado
|
||||
t.timestamps(true, true)
|
||||
})
|
||||
await db.raw('CREATE INDEX IF NOT EXISTS idx_approvals_dent ON sec_agenda_approvals (dentista_jid, status)')
|
||||
}
|
||||
|
||||
// ── Seeds: agente padrão + nós + calendário ───────────────────────────────
|
||||
const agentCount = await db('sec_agents').count('id as c').first()
|
||||
if (Number(agentCount?.c ?? 0) === 0) {
|
||||
|
||||
@@ -432,6 +432,60 @@ exports.BUILTIN_TOOLS = [
|
||||
}
|
||||
},
|
||||
},
|
||||
// ── perguntar_dentista ──────────────────────────────────────────────────────
|
||||
// Parte A: encaixe SUB-HORA (ex.: 09:45, que quebra o espaçamento de 1h). A IA
|
||||
// pergunta ao DENTISTA por WhatsApp; ele responde SIM/NÃO e o sistema agenda.
|
||||
{
|
||||
name: 'perguntar_dentista',
|
||||
description: 'Use quando o cliente pedir um encaixe que QUEBRA o espaçamento de 1 hora (ex.: 09:45, 10:20) — um horário ' +
|
||||
'curto/fora da grade normal. NÃO agende: pergunte ao DENTISTA responsável se dá tempo. Diga ao cliente que ' +
|
||||
'você vai verificar com o dentista e confirma em seguida.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
dentista: { type: 'string', description: 'Nome do dentista responsável.' },
|
||||
data: { type: 'string', description: 'Data YYYY-MM-DD.' },
|
||||
hora: { type: 'string', description: 'Horário do encaixe HH:MM (ex.: 09:45).' },
|
||||
procedimento: { type: 'string', description: 'Tipo (ex.: avaliação, avaliação de prótese).' },
|
||||
nome_cliente: { type: 'string', description: 'Nome do cliente.' },
|
||||
telefone_cliente: { type: 'string', description: 'Telefone do cliente (opcional).' },
|
||||
},
|
||||
required: ['dentista', 'data', 'hora', 'nome_cliente'],
|
||||
},
|
||||
async execute(args, ctx) {
|
||||
if (!ctx.agenda?.url || !ctx.agenda.clinicaId)
|
||||
return { ok: false, erro: 'Agenda indisponível.' };
|
||||
const jid = (ctx.extChatId ?? '').split(':').pop() ?? '';
|
||||
if (!jid || !ctx.instanceId)
|
||||
return { ok: false, erro: 'Contexto incompleto.' };
|
||||
let telefone = String(args['telefone_cliente'] ?? '').replace(/\D/g, '');
|
||||
if (!telefone)
|
||||
telefone = (jid.split('@')[0] ?? '').replace(/\D/g, '');
|
||||
try {
|
||||
const u = new URL(`${ctx.agenda.url.replace(/\/$/, '')}/dentista-contato`);
|
||||
u.searchParams.set('clinica_id', ctx.agenda.clinicaId);
|
||||
u.searchParams.set('nome', String(args['dentista']));
|
||||
const r = await fetch(u, { headers: { 'x-nw-agenda-secret': ctx.agenda.secret ?? '' }, signal: AbortSignal.timeout(10000) });
|
||||
const dj = await r.json().catch(() => ({}));
|
||||
if (!dj.encontrado || !dj.telefone)
|
||||
return { ok: false, mensagem: 'Sem contato do dentista.', instrucao: 'Diga que não conseguiu falar com o dentista agora e ofereça um horário normal da agenda.' };
|
||||
const dentistaJid = `${dj.telefone}@s.whatsapp.net`;
|
||||
await ctx.db('sec_agenda_approvals').insert({
|
||||
tenant_id: ctx.tenantId ?? null, instance_id: ctx.instanceId, clinica_id: ctx.agenda.clinicaId,
|
||||
agenda_url: ctx.agenda.url, agenda_secret: ctx.agenda.secret ?? '',
|
||||
paciente_jid: jid, paciente_nome: args['nome_cliente'] ?? null, paciente_telefone: telefone || null,
|
||||
dentista_id: dj.dentista_id, dentista_nome: dj.nome, dentista_jid: dentistaJid,
|
||||
data: args['data'], hora: args['hora'], procedimento: args['procedimento'] ?? 'avaliação', status: 'aguardando',
|
||||
});
|
||||
const texto = `Oi, Dr(a) ${String(dj.nome).split(' ')[0]}! 👋 Tenho um paciente (${args['nome_cliente']}) querendo ${args['procedimento'] ?? 'uma avaliação'} às ${args['hora']} do dia ${args['data']}. Consegue encaixar? Responda *SIM* ou *NÃO*.`;
|
||||
ctx.hooks?.emit?.('ext:agenda.notify-dentist', { instanceId: ctx.instanceId, tenantId: ctx.tenantId, jid: dentistaJid, text: texto });
|
||||
return { ok: true, perguntou: true, instrucao: 'Diga ao cliente que você já perguntou ao dentista se dá pra encaixar e que, assim que ele responder, você confirma. NÃO garanta o horário.' };
|
||||
}
|
||||
catch (e) {
|
||||
return { ok: false, erro: e.message };
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
function resolveTools(names) {
|
||||
return names
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -470,6 +470,54 @@ export const BUILTIN_TOOLS: ToolDef[] = [
|
||||
} catch (e: any) { return { ok: false, erro: e.message } }
|
||||
},
|
||||
},
|
||||
|
||||
// ── perguntar_dentista ──────────────────────────────────────────────────────
|
||||
// Parte A: encaixe SUB-HORA (ex.: 09:45, que quebra o espaçamento de 1h). A IA
|
||||
// pergunta ao DENTISTA por WhatsApp; ele responde SIM/NÃO e o sistema agenda.
|
||||
{
|
||||
name: 'perguntar_dentista',
|
||||
description:
|
||||
'Use quando o cliente pedir um encaixe que QUEBRA o espaçamento de 1 hora (ex.: 09:45, 10:20) — um horário ' +
|
||||
'curto/fora da grade normal. NÃO agende: pergunte ao DENTISTA responsável se dá tempo. Diga ao cliente que ' +
|
||||
'você vai verificar com o dentista e confirma em seguida.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
dentista: { type: 'string', description: 'Nome do dentista responsável.' },
|
||||
data: { type: 'string', description: 'Data YYYY-MM-DD.' },
|
||||
hora: { type: 'string', description: 'Horário do encaixe HH:MM (ex.: 09:45).' },
|
||||
procedimento: { type: 'string', description: 'Tipo (ex.: avaliação, avaliação de prótese).' },
|
||||
nome_cliente: { type: 'string', description: 'Nome do cliente.' },
|
||||
telefone_cliente: { type: 'string', description: 'Telefone do cliente (opcional).' },
|
||||
},
|
||||
required: ['dentista', 'data', 'hora', 'nome_cliente'],
|
||||
},
|
||||
async execute(args, ctx) {
|
||||
if (!ctx.agenda?.url || !ctx.agenda.clinicaId) return { ok: false, erro: 'Agenda indisponível.' }
|
||||
const jid = (ctx.extChatId ?? '').split(':').pop() ?? ''
|
||||
if (!jid || !ctx.instanceId) return { ok: false, erro: 'Contexto incompleto.' }
|
||||
let telefone = String(args['telefone_cliente'] ?? '').replace(/\D/g, '')
|
||||
if (!telefone) telefone = (jid.split('@')[0] ?? '').replace(/\D/g, '')
|
||||
try {
|
||||
const u = new URL(`${ctx.agenda.url.replace(/\/$/, '')}/dentista-contato`)
|
||||
u.searchParams.set('clinica_id', ctx.agenda.clinicaId); u.searchParams.set('nome', String(args['dentista']))
|
||||
const r = await fetch(u, { headers: { 'x-nw-agenda-secret': ctx.agenda.secret ?? '' }, signal: AbortSignal.timeout(10_000) })
|
||||
const dj: any = await r.json().catch(() => ({}))
|
||||
if (!dj.encontrado || !dj.telefone) return { ok: false, mensagem: 'Sem contato do dentista.', instrucao: 'Diga que não conseguiu falar com o dentista agora e ofereça um horário normal da agenda.' }
|
||||
const dentistaJid = `${dj.telefone}@s.whatsapp.net`
|
||||
await ctx.db('sec_agenda_approvals').insert({
|
||||
tenant_id: ctx.tenantId ?? null, instance_id: ctx.instanceId, clinica_id: ctx.agenda.clinicaId,
|
||||
agenda_url: ctx.agenda.url, agenda_secret: ctx.agenda.secret ?? '',
|
||||
paciente_jid: jid, paciente_nome: args['nome_cliente'] ?? null, paciente_telefone: telefone || null,
|
||||
dentista_id: dj.dentista_id, dentista_nome: dj.nome, dentista_jid: dentistaJid,
|
||||
data: args['data'], hora: args['hora'], procedimento: args['procedimento'] ?? 'avaliação', status: 'aguardando',
|
||||
})
|
||||
const texto = `Oi, Dr(a) ${String(dj.nome).split(' ')[0]}! 👋 Tenho um paciente (${args['nome_cliente']}) querendo ${args['procedimento'] ?? 'uma avaliação'} às ${args['hora']} do dia ${args['data']}. Consegue encaixar? Responda *SIM* ou *NÃO*.`
|
||||
ctx.hooks?.emit?.('ext:agenda.notify-dentist', { instanceId: ctx.instanceId, tenantId: ctx.tenantId, jid: dentistaJid, text: texto })
|
||||
return { ok: true, perguntou: true, instrucao: 'Diga ao cliente que você já perguntou ao dentista se dá pra encaixar e que, assim que ele responder, você confirma. NÃO garanta o horário.' }
|
||||
} catch (e: any) { return { ok: false, erro: e.message } }
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
export function resolveTools(names: string[]): ToolDef[] {
|
||||
|
||||
Reference in New Issue
Block a user