"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.ALL_TOOL_NAMES = exports.BUILTIN_TOOLS = void 0; exports.resolveTools = resolveTools; // ── Helpers ──────────────────────────────────────────────────────────────────── function fmtDate(d) { return d.toISOString().split('T')[0]; } // ── Tools ────────────────────────────────────────────────────────────────────── exports.BUILTIN_TOOLS = [ // ── listar_horarios ────────────────────────────────────────────────────────── { name: 'listar_horarios', description: 'Lista os horários disponíveis na agenda para agendamento. ' + 'Use antes de propor datas ao cliente. ' + 'Se não informar a data, retorna os próximos 7 dias.', parameters: { type: 'object', properties: { data: { type: 'string', description: 'Data no formato YYYY-MM-DD. Opcional — padrão: próximos 7 dias.', }, periodo: { type: 'string', description: 'Período do dia: "manha" ou "tarde". SEMPRE informe (pergunte ao cliente antes se não souber).', enum: ['manha', 'tarde'], }, }, }, async execute(args, ctx) { // Agenda REAL do scoreodonto (ponte) quando configurada. if (ctx.agenda?.url && ctx.agenda.clinicaId) { try { const u = new URL(`${ctx.agenda.url.replace(/\/$/, '')}/slots`); u.searchParams.set('clinica_id', ctx.agenda.clinicaId); if (args['data']) u.searchParams.set('date', String(args['data'])); if (args['periodo']) u.searchParams.set('periodo', String(args['periodo'])); const r = await fetch(u, { headers: { 'x-nw-agenda-secret': ctx.agenda.secret ?? '' }, signal: AbortSignal.timeout(12000), }); const j = await r.json().catch(() => ({})); const slots = j?.slots ?? []; if (!slots.length) return { disponivel: false, mensagem: j?.motivo ?? 'Nenhum horário disponível no período.' }; return { disponivel: true, horarios: slots.map((s) => ({ // slot_id sintético (não há registro persistente — o slot é calculado). // Inclui sala_id (vazio p/ clínica) p/ o agendar saber onde reservar. id: `${s.dentista_id}|${s.start}|${s.end}|${s.sala_id ?? ''}`, dentista: s.dentista_nome, data: String(s.start).slice(0, 10), inicio: String(s.start).slice(11, 16), fim: String(s.end).slice(11, 16), local: s.local ?? 'Clínica', // nome da sala alugada, ou "Clínica" })), }; } catch (e) { return { disponivel: false, mensagem: `Agenda indisponível: ${e.message}` }; } } // Fallback: calendário interno do motor, escopado por sessão (instance_id). // Slots com instance_id NULL são legado/global (visíveis a todas as sessões). const from = args['data'] ?? fmtDate(new Date()); const to = args['data'] ?? fmtDate(new Date(Date.now() + 7 * 86400000)); const slots = await ctx.db('sec_calendar') .where('status', 'available') .whereBetween('date', [from, to]) .where((qb) => { qb.whereNull('instance_id'); if (ctx.instanceId) qb.orWhere('instance_id', ctx.instanceId); }) .orderBy('date').orderBy('time_start') .limit(20); if (!slots.length) { return { disponivel: false, mensagem: 'Nenhum horário disponível no período informado.' }; } return { disponivel: true, horarios: slots.map(s => ({ id: s.id, data: s.date, inicio: String(s.time_start).slice(0, 5), fim: String(s.time_end).slice(0, 5), titulo: s.title, })), }; }, }, // ── agendar_horario ────────────────────────────────────────────────────────── { name: 'agendar_horario', description: 'Reserva um horário disponível na agenda para o cliente. ' + 'Use listar_horarios primeiro para obter o slot_id correto.', parameters: { type: 'object', properties: { slot_id: { type: 'string', description: 'ID do horário retornado por listar_horarios.' }, nome_cliente: { type: 'string', description: 'Nome completo do cliente.' }, telefone_cliente: { type: 'string', description: 'Telefone do cliente (opcional).' }, }, required: ['slot_id', 'nome_cliente'], }, async execute(args, ctx) { // Agenda REAL do scoreodonto (ponte) quando configurada. if (ctx.agenda?.url && ctx.agenda.clinicaId) { const parts = String(args['slot_id'] ?? '').split('|'); if (parts.length < 3) return { ok: false, erro: 'slot_id inválido. Chame listar_horarios novamente.' }; const [dentistaId, start, end, salaId] = parts; // salaId vazio = agenda da clínica // telefone: do argumento OU derivado do JID do contato (extChatId = tenant:jid) let telefone = String(args['telefone_cliente'] ?? '').replace(/\D/g, ''); if (!telefone && ctx.extChatId) { // ext_chat_id = "tenant:jid" (legado) ou "tenant:instanceId:jid"; o jid // não tem ':', então é sempre o último segmento. const jid = ctx.extChatId.split(':').pop() ?? ''; telefone = (jid.split('@')[0] ?? '').replace(/\D/g, ''); } try { const r = await fetch(`${ctx.agenda.url.replace(/\/$/, '')}/book`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-nw-agenda-secret': ctx.agenda.secret ?? '' }, signal: AbortSignal.timeout(12000), body: JSON.stringify({ clinica_id: ctx.agenda.clinicaId, dentista_id: dentistaId, start, end, ...(salaId ? { sala_id: salaId } : {}), // reserva no quarto alugado, se aplicável paciente_nome: args['nome_cliente'], paciente_celular: telefone || null, }), }); const j = await r.json().catch(() => ({})); if (r.status === 409) return { ok: false, erro: 'Esse horário acabou de ser ocupado. Chame listar_horarios novamente.' }; if (!r.ok) return { ok: false, erro: j?.error ?? 'Falha ao agendar.' }; return { ok: true, confirmacao: { data: String(start).slice(0, 10), inicio: String(start).slice(11, 16), fim: String(end).slice(11, 16), nome: args['nome_cliente'], paciente_vinculado: !!j?.paciente_vinculado, }, }; } catch (e) { return { ok: false, erro: `Agenda indisponível: ${e.message}` }; } } // Fallback: calendário interno do motor. const slot = await ctx.db('sec_calendar') .where({ id: args['slot_id'], status: 'available' }) .first(); if (!slot) { return { ok: false, erro: 'Horário não encontrado ou já reservado. Chame listar_horarios novamente.' }; } await ctx.db('sec_calendar').where({ id: args['slot_id'] }).update({ status: 'booked', attendee_name: args['nome_cliente'], attendee_phone: args['telefone_cliente'] ?? null, updated_at: new Date(), }); return { ok: true, confirmacao: { data: slot.date, inicio: String(slot.time_start).slice(0, 5), fim: String(slot.time_end).slice(0, 5), titulo: slot.title, nome: args['nome_cliente'], }, }; }, }, // ── identificar_numero ─────────────────────────────────────────────────────── { name: 'identificar_numero', description: 'SEMPRE use PRIMEIRO ao iniciar cadastro ou agendamento. Retorna quais pacientes já ' + 'estão cadastrados NESTE número de WhatsApp (a "família" do número). Lista vazia = número novo. ' + 'Use para: (1) saber se quem fala já é paciente; (2) ver se há dependentes no mesmo número ' + '(ex.: mãe + filhos); (3) decidir a quem se refere o atendimento antes de agendar.', parameters: { type: 'object', properties: {} }, async execute(_args, ctx) { if (!ctx.agenda?.url) return { erro: 'Ponte de agenda indisponível.' }; const phone = (((ctx.extChatId ?? '').split(':').pop() ?? '').split('@')[0] ?? '').replace(/\D/g, ''); if (!phone) return { erro: 'Não foi possível identificar o número desta conversa.' }; try { const u = new URL(`${ctx.agenda.url.replace(/\/$/, '')}/pacientes/lookup`); u.searchParams.set('clinica_id', ctx.agenda.clinicaId ?? ''); u.searchParams.set('phone', phone); const r = await fetch(u, { headers: { 'x-nw-agenda-secret': ctx.agenda.secret ?? '' }, signal: AbortSignal.timeout(10000) }); const j = await r.json().catch(() => ({})); if (!r.ok) return { erro: j?.error ?? 'Falha ao consultar a base.' }; return { numero: phone, cadastrado: (j?.encontrados ?? 0) > 0, pacientes: j?.pacientes ?? [], dica: (j?.encontrados ?? 0) > 1 ? 'Há mais de um paciente neste número (grupo familiar) — confirme para quem é.' : (j?.encontrados === 1 ? 'Confirme se quem fala é este paciente.' : 'Número novo — colete os dados para cadastrar.'), }; } catch (e) { return { erro: `Base indisponível: ${e.message}` }; } }, }, // ── cadastrar_paciente ─────────────────────────────────────────────────────── { name: 'cadastrar_paciente', description: 'Cadastra (ou atualiza) um paciente. REGRAS: ' + '(1) Se quem fala é o próprio paciente adulto → eh_titular=true. ' + '(2) Se está cadastrando um DEPENDENTE (ex.: filho menor de 18) → NÃO marque eh_titular; ' + 'o sistema vincula ao responsável (o dono deste número) e usa o telefone da família. ' + '(3) Menor de 18 é dependente POR PADRÃO — mas se o menor tiver WhatsApp próprio e estiver ' + 'falando do número dele, pergunte e trate como titular. ' + '(4) SEMPRE pergunte a data de nascimento (a idade decide se é dependente). ' + '(5) CPF é o identificador forte (evita duplicar) — colete para adultos. ' + 'Use identificar_numero antes para não duplicar quem já existe.', parameters: { type: 'object', properties: { nome: { type: 'string', description: 'Nome completo do paciente.' }, data_nascimento: { type: 'string', description: 'Data de nascimento YYYY-MM-DD (define a idade).' }, cpf: { type: 'string', description: 'CPF do paciente (recomendado para adultos).' }, telefone: { type: 'string', description: 'Telefone próprio do paciente. Omita se for dependente sem número próprio.' }, convenio: { type: 'string', description: 'Convênio/plano, se houver.' }, eh_titular: { type: 'string', description: '"true" se quem fala é o próprio paciente (dono deste número).' }, relacao: { type: 'string', description: 'Relação no grupo (ex.: filho(a), cônjuge, dependente). Opcional.' }, responsavel_cpf: { type: 'string', description: 'CPF do responsável, se cadastrando um dependente.' }, }, required: ['nome'], }, async execute(args, ctx) { if (!ctx.agenda?.url) return { ok: false, erro: 'Ponte de agenda indisponível.' }; const convPhone = (((ctx.extChatId ?? '').split(':').pop() ?? '').split('@')[0] ?? '').replace(/\D/g, ''); const ehTitular = String(args['eh_titular'] ?? '').toLowerCase() === 'true'; const body = { clinica_id: ctx.agenda.clinicaId, nome: args['nome'], cpf: args['cpf'] || null, data_nascimento: args['data_nascimento'] || null, telefone: args['telefone'] || (ehTitular ? convPhone : null), convenio: args['convenio'] || null, eh_titular: ehTitular, relacao: args['relacao'] || null, responsavel_cpf: args['responsavel_cpf'] || null, // Dependente sem responsável explícito → o dono DESTE número é o responsável. responsavel_telefone: (!ehTitular && !args['responsavel_cpf']) ? convPhone : null, }; try { const r = await fetch(`${ctx.agenda.url.replace(/\/$/, '')}/pacientes`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-nw-agenda-secret': ctx.agenda.secret ?? '' }, signal: AbortSignal.timeout(12000), body: JSON.stringify(body), }); const j = await r.json().catch(() => ({})); if (!r.ok) return { ok: false, erro: j?.error ?? 'Falha ao cadastrar.' }; return { ok: true, ...j }; } catch (e) { return { ok: false, erro: `Base indisponível: ${e.message}` }; } }, }, // ── escalar_humano ─────────────────────────────────────────────────────────── { name: 'escalar_humano', description: 'Transfere o atendimento para um atendente humano quando a situação exige. ' + 'Use quando: cliente pede explicitamente, problema financeiro sensível, raiva intensa, ' + 'ou quando você não consegue resolver após tentativas honestas.', parameters: { type: 'object', properties: { motivo: { type: 'string', description: 'Motivo da transferência (ex: "cliente insatisfeito com cobrança").', }, }, }, async execute(args, ctx) { await ctx.db('sec_conversations').where({ id: ctx.conversationId }).update({ handoff_mode: 'humano', handoff_human_at: new Date(), status: 'escalated', }); const conv = await ctx.db('sec_conversations').where({ id: ctx.conversationId }).first(); if (ctx.hooks && ctx.tenantId && ctx.extChatId) { // jid = último segmento (formato "tenant:jid" ou "tenant:instanceId:jid"). const chatId = ctx.extChatId.split(':').pop() ?? ctx.extChatId; const payload = { tenantId: ctx.tenantId, conversationId: ctx.conversationId, chatId, protocolNumber: conv?.protocol_number ?? '', motivo: args['motivo'] ?? '', }; ctx.hooks.emit('ext:handoff', { ...payload, mode: 'humano', reason: 'escalation' }).catch(() => { }); ctx.hooks.emit('ext:escalated', payload).catch(() => { }); } return { ok: true, escalado: true, motivo: args['motivo'] ?? 'Solicitado pelo sistema' }; }, }, // ── encerrar_protocolo ─────────────────────────────────────────────────────── { name: 'encerrar_protocolo', description: 'Encerra o protocolo de atendimento após resolver o problema do cliente. ' + 'Use apenas quando tiver certeza de que tudo foi resolvido.', parameters: { type: 'object', properties: { resumo: { type: 'string', description: 'Breve resumo do que foi tratado e resolvido neste atendimento.', }, }, required: ['resumo'], }, async execute(args, ctx) { const summary = args['resumo'] ?? 'Atendimento encerrado.'; const conv = await ctx.db('sec_conversations').where({ id: ctx.conversationId }).first(); await ctx.db('sec_conversations').where({ id: ctx.conversationId }).update({ status: 'closed', summary, updated_at: new Date(), }); await ctx.db('sec_messages').where({ conversation_id: ctx.conversationId }).delete(); return { ok: true, protocolo: conv?.protocol_number ?? '', resumo: summary }; }, }, // ── solicitar_horario_especial ────────────────────────────────────────────── // Zona cinza: o cliente pede um horário FORA da janela livre (mais cedo/mais // tarde). A IA NÃO agenda — registra um pedido para a secretária HUMANA confirmar // e diz que vai retornar. { name: 'solicitar_horario_especial', description: 'Use QUANDO o cliente pedir um horário que NÃO aparece na lista de horários disponíveis — ' + 'mais cedo ou mais tarde que o normal (ex.: "tem às 8h30?", "consegue depois das 17h?"). ' + 'NUNCA diga que não tem: registre o pedido com esta tool 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?". ' + 'Não garanta o horário nem mencione "secretária". Só para horários pontuais fora do padrão.', parameters: { type: 'object', properties: { data: { type: 'string', description: 'Data desejada no formato YYYY-MM-DD.' }, hora: { type: 'string', description: 'Horário desejado HH:MM.' }, nome_cliente: { type: 'string', description: 'Nome completo do cliente.' }, telefone_cliente: { type: 'string', description: 'Telefone do cliente (opcional).' }, dentista: { type: 'string', description: 'Nome do dentista, se o cliente indicou (opcional).' }, procedimento: { type: 'string', description: 'Procedimento/motivo (opcional).' }, }, required: ['data', 'hora', 'nome_cliente'], }, async execute(args, ctx) { if (!ctx.agenda?.url || !ctx.agenda.clinicaId) return { ok: false, erro: 'Agenda indisponível.' }; let telefone = String(args['telefone_cliente'] ?? '').replace(/\D/g, ''); if (!telefone && ctx.extChatId) { const jid = ctx.extChatId.split(':').pop() ?? ''; telefone = (jid.split('@')[0] ?? '').replace(/\D/g, ''); } try { const r = await fetch(`${ctx.agenda.url.replace(/\/$/, '')}/pedido`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-nw-agenda-secret': ctx.agenda.secret ?? '' }, signal: AbortSignal.timeout(12000), body: JSON.stringify({ clinica_id: ctx.agenda.clinicaId, data: args['data'], hora: args['hora'], dentista_nome: args['dentista'] ?? null, nome_cliente: args['nome_cliente'], telefone, procedimento: args['procedimento'] ?? null, }), }); const j = await r.json().catch(() => ({})); if (!r.ok || j.ok === false) return { ok: false, mensagem: j.motivo ?? 'Não foi possível registrar o pedido.' }; return { ok: true, registrado: true, instrucao: 'Responda ao cliente 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?". NÃO garanta o horário nem mencione "secretária"; deixe claro que confirma em breve.' }; } catch (e) { return { ok: false, erro: e.message }; } }, }, ]; function resolveTools(names) { return names .map(n => exports.BUILTIN_TOOLS.find(t => t.name === n)) .filter((t) => t !== undefined); } exports.ALL_TOOL_NAMES = exports.BUILTIN_TOOLS.map(t => t.name); //# sourceMappingURL=tools.js.map