Files
clube67_newwhats.local/newwhats.clube67.com/newwhats.local/plugins/secretaria/tools.ts
T
VPS 4 Deploy Agent eca65bf765 feat(secretaria): roteamento por especialidade, situações, dúvida→humano, imagem, checklist do dono e desligar global
Secretária IA mais segura e contextual:
- Roteamento por especialidade: /contexto (dentistas+especialidades) injetado no
  cérebro; descobrir o motivo e priorizar clínico geral só p/ avaliação/limpeza.
- Situações da clínica: regras editáveis injetadas no prompt (siga à risca).
- Dúvida/caso incerto → tool encaminhar_humano (não agenda; pede p/ aguardar).
- Imagem/áudio: ext:message.transcription religa no fluxo ext:message.new
  (guard hadCaption evita processar 2x); a IA reage à descrição da imagem.
- Checklist do dono × banco → cautelas no /contexto: NÃO assumir clínico geral
  por padrão p/ quem já é paciente; sem certeza de quem/qual dentista → humano.
- Desligar GLOBAL da Secretária: GET/POST /secretaria/power (kill-switch
  auto_reply via config.set, efeito imediato, sem restart; visível a todos).

Inclui a plumbing da sessão (chat.upsert, ownership de conexão, ws-bridge).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 15:40:26 +02:00

571 lines
31 KiB
TypeScript

/**
* Secretaria — Tool Definitions
*
* Cada ToolDef é uma função que a IA pode chamar durante o atendimento.
* O motor executa a função, injeta o resultado e chama a IA novamente.
*
* Tools disponíveis:
* listar_horarios — agenda: horários livres
* agendar_horario — agenda: reserva um slot
* escalar_humano — handoff: transfere para atendente
* encerrar_protocolo — fecha o protocolo com resumo
*/
import { Knex } from 'knex'
import type { HookBus } from '../../backend/src/core/hook-bus'
// ── Tipos públicos ─────────────────────────────────────────────────────────────
export interface ToolParam {
type: string
description: string
enum?: string[]
}
export interface ToolDef {
name: string
description: string
parameters: {
type: 'object'
properties: Record<string, ToolParam>
required?: string[]
}
execute: (args: Record<string, any>, ctx: ToolContext) => Promise<any>
}
export interface ToolContext {
db: Knex
conversationId: string
extChatId?: string
tenantId?: string
/** Número/sessão que recebeu a mensagem — escopa o calendário interno por sessão. */
instanceId?: string
/** Atraso (min) do follow-up proativo da agenda (Parte B). Default 15. */
followupDelayMin?: number
hooks?: HookBus
/** Ponte de agenda do satélite (scoreodonto). Quando presente, as tools de
* agenda operam na agenda REAL da clínica em vez do calendário interno. */
agenda?: { url?: string; secret?: string; clinicaId?: string }
/** Telemetria cumulativa preenchida pelos tool loops (M1.4). */
_telemetry?: {
usage: { input: number; output: number; total: number; cache_read?: number; cached?: number }
provider: string
model: string
iterations: number
}
}
// ── Helpers ────────────────────────────────────────────────────────────────────
function fmtDate(d: Date): string {
return d.toISOString().split('T')[0]!
}
// ── Tools ──────────────────────────────────────────────────────────────────────
export const BUILTIN_TOOLS: ToolDef[] = [
// ── 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(12_000),
})
const j: any = await r.json().catch(() => ({}))
const slots: any[] = 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: any) {
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 * 86_400_000))
const slots = await ctx.db('sec_calendar')
.where('status', 'available')
.whereBetween('date', [from, to])
.where((qb: any) => {
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 as any[]).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(12_000),
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: any = 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: any) {
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: Record<string, any>, ctx: ToolContext) {
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(10_000) })
const j: any = 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: any) { 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: Record<string, any>, ctx: ToolContext) {
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: any = {
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(12_000),
body: JSON.stringify(body),
})
const j: any = await r.json().catch(() => ({}))
if (!r.ok) return { ok: false, erro: j?.error ?? 'Falha ao cadastrar.' }
return { ok: true, ...j }
} catch (e: any) { 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(12_000),
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: any = 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: any) { return { ok: false, erro: e.message } }
},
},
// ── encaminhar_humano ───────────────────────────────────────────────────────
// Dúvida/caso incerto (ou regra da clínica que mande AVALIAR): a IA NÃO agenda —
// encaminha à secretária humana decidir e pede ao cliente para aguardar.
{
name: 'encaminhar_humano',
description:
'Use QUANDO NÃO for caso de agendar direto: dúvida inicial do cliente, caso clínico que você não sabe ' +
'resolver, imagem/foto que precise de avaliação humana, ou quando uma REGRA/SITUAÇÃO da clínica mandar ' +
'AVALIAR antes (ex.: "pelo plano não temos profissional para canal posterior — ver chance de agendar ' +
'avaliação"). NÃO agende: registre com esta tool e peça ao cliente para AGUARDAR, que a clínica retorna ' +
'com uma posição. Não mencione "secretária humana" nem garanta nada. Nem sempre o certo é agendar.',
parameters: {
type: 'object',
properties: {
resumo: { type: 'string', description: 'Resumo objetivo da dúvida/situação para a secretária humana decidir (inclua o que o cliente quer, o motivo e, se houve imagem, o que você identificou).' },
nome_cliente: { type: 'string', description: 'Nome do cliente, se souber (opcional).' },
telefone_cliente: { type: 'string', description: 'Telefone do cliente (opcional).' },
},
required: ['resumo'],
},
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(/\/$/, '')}/duvida`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-nw-agenda-secret': ctx.agenda.secret ?? '' },
signal: AbortSignal.timeout(12_000),
body: JSON.stringify({
clinica_id: ctx.agenda.clinicaId, resumo: args['resumo'],
nome_cliente: args['nome_cliente'] ?? null, telefone,
}),
})
const j: any = await r.json().catch(() => ({}))
if (!r.ok || j.ok === false) return { ok: false, mensagem: j.error ?? 'Não foi possível encaminhar.' }
return { ok: true, registrado: true, instrucao: 'Responda ao cliente que você vai verificar isso com a clínica e retorna em breve com uma posição — peça para aguardar. NÃO agende, NÃO garanta nada e NÃO mencione "secretária".' }
} catch (e: any) { return { ok: false, erro: e.message } }
},
},
// ── confirmar_depois ────────────────────────────────────────────────────────
// Parte B: o cliente pede um horário MAIS TARDE que o oferecido. A IA diz "vou
// confirmar, aguarde uns minutinhos" e registra um follow-up; um worker retorna
// depois (com atraso) com o horário confirmado.
{
name: 'confirmar_depois',
description:
'Use quando o cliente pedir um horário MAIS TARDE no dia (dentro do mesmo período) do que o que você ' +
'ofereceu — ou quando quiser "checar a agenda com calma" antes de confirmar. Diga ao cliente para aguardar ' +
'uns minutinhos que você vai confirmar; em poucos minutos o sistema retorna sozinho com o horário. NÃO ofereça horário agora.',
parameters: {
type: 'object',
properties: {
data: { type: 'string', description: 'Data desejada YYYY-MM-DD.' },
periodo: { type: 'string', description: '"manha" ou "tarde".', enum: ['manha', 'tarde'] },
hora_desejada: { type: 'string', description: 'Horário aproximado que o cliente pediu (HH:MM), se houver.' },
nome_cliente: { type: 'string', description: 'Nome do cliente.' },
dentista: { type: 'string', description: 'Dentista, se indicado (opcional).' },
},
required: ['data'],
},
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.' }
const delay = Math.max(1, ctx.followupDelayMin ?? 15)
try {
await ctx.db('sec_agenda_followups').insert({
tenant_id: ctx.tenantId ?? null, instance_id: ctx.instanceId, jid,
clinica_id: ctx.agenda.clinicaId, agenda_url: ctx.agenda.url, agenda_secret: ctx.agenda.secret ?? '',
dentista_nome: args['dentista'] ?? null, data: args['data'], hora_desejada: args['hora_desejada'] ?? null,
periodo: args['periodo'] ?? null, paciente_nome: args['nome_cliente'] ?? null,
fire_at: new Date(Date.now() + delay * 60_000), status: 'pendente',
})
return { ok: true, agendado_retorno: true, instrucao: 'Diga ao cliente para aguardar uns minutinhos que você vai confirmar a agenda e JÁ retorna. NÃO ofereça horário agora — o sistema retorna sozinho em instantes.' }
} 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[] {
return names
.map(n => BUILTIN_TOOLS.find(t => t.name === n))
.filter((t): t is ToolDef => t !== undefined)
}
export const ALL_TOOL_NAMES = BUILTIN_TOOLS.map(t => t.name)