feat(secretaria): tools de agenda operam na agenda real do satélite via ponte
listar_horarios/agendar_horario chamam a ponte do scoreodonto (/api/nw/agenda slots|book) quando configurada — slot_id sintético (dentista|start|end), telefone derivado do JID do contato — com fallback ao calendário interno preservado. brain.ts injeta a config da agenda (agenda_url/secret/clinica_id) no toolCtx. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -38,6 +38,9 @@ export interface ToolContext {
|
||||
extChatId?: string
|
||||
tenantId?: string
|
||||
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 }
|
||||
@@ -74,6 +77,35 @@ export const BUILTIN_TOOLS: ToolDef[] = [
|
||||
},
|
||||
},
|
||||
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']))
|
||||
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)
|
||||
id: `${s.dentista_id}|${s.start}|${s.end}`,
|
||||
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),
|
||||
})),
|
||||
}
|
||||
} catch (e: any) {
|
||||
return { disponivel: false, mensagem: `Agenda indisponível: ${e.message}` }
|
||||
}
|
||||
}
|
||||
// Fallback: calendário interno do motor.
|
||||
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')
|
||||
@@ -113,6 +145,45 @@ export const BUILTIN_TOOLS: ToolDef[] = [
|
||||
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] = parts
|
||||
// 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) {
|
||||
const jid = ctx.extChatId.split(':').slice(1).join(':')
|
||||
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,
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user