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:
VPS 4 Deploy Agent
2026-07-02 00:51:47 +02:00
parent 80dc5dd135
commit d09155613c
6 changed files with 164 additions and 2 deletions
@@ -57,12 +57,20 @@ class ProtocolEngine {
// Resolve tools: usa lista passada por opts, ou todas as builtins por padrão // Resolve tools: usa lista passada por opts, ou todas as builtins por padrão
const toolNames = opts?.tools ?? tools_1.ALL_TOOL_NAMES; const toolNames = opts?.tools ?? tools_1.ALL_TOOL_NAMES;
const toolDefs = (0, tools_1.resolveTools)(toolNames); const toolDefs = (0, tools_1.resolveTools)(toolNames);
const secCfg = (await this.config.get('secretaria'));
const toolCtx = { const toolCtx = {
db: this.db, db: this.db,
conversationId, conversationId,
extChatId: conversation.ext_chat_id ?? undefined, extChatId: conversation.ext_chat_id ?? undefined,
tenantId: opts?.tenantId, tenantId: opts?.tenantId,
hooks: opts?.hooks, hooks: opts?.hooks,
// Ponte de agenda do satélite (scoreodonto) — as tools de agenda operam
// na agenda real da clínica quando configurada.
agenda: {
url: secCfg?.agenda_url,
secret: secCfg?.agenda_secret,
clinicaId: secCfg?.clinica_id,
},
}; };
let response; let response;
let usageInfo = null; let usageInfo = null;
File diff suppressed because one or more lines are too long
@@ -78,12 +78,20 @@ export class ProtocolEngine {
const toolNames = opts?.tools ?? ALL_TOOL_NAMES const toolNames = opts?.tools ?? ALL_TOOL_NAMES
const toolDefs = resolveTools(toolNames) const toolDefs = resolveTools(toolNames)
const secCfg = (await this.config.get('secretaria')) as any
const toolCtx: ToolContext = { const toolCtx: ToolContext = {
db: this.db, db: this.db,
conversationId, conversationId,
extChatId: conversation.ext_chat_id ?? undefined, extChatId: conversation.ext_chat_id ?? undefined,
tenantId: opts?.tenantId, tenantId: opts?.tenantId,
hooks: opts?.hooks, hooks: opts?.hooks,
// Ponte de agenda do satélite (scoreodonto) — as tools de agenda operam
// na agenda real da clínica quando configurada.
agenda: {
url: secCfg?.agenda_url,
secret: secCfg?.agenda_secret,
clinicaId: secCfg?.clinica_id,
},
} }
let response: string let response: string
@@ -24,6 +24,38 @@ exports.BUILTIN_TOOLS = [
}, },
}, },
async execute(args, ctx) { 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(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)
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) {
return { disponivel: false, mensagem: `Agenda indisponível: ${e.message}` };
}
}
// Fallback: calendário interno do motor.
const from = args['data'] ?? fmtDate(new Date()); const from = args['data'] ?? fmtDate(new Date());
const to = args['data'] ?? fmtDate(new Date(Date.now() + 7 * 86400000)); const to = args['data'] ?? fmtDate(new Date(Date.now() + 7 * 86400000));
const slots = await ctx.db('sec_calendar') const slots = await ctx.db('sec_calendar')
@@ -61,6 +93,49 @@ exports.BUILTIN_TOOLS = [
required: ['slot_id', 'nome_cliente'], required: ['slot_id', 'nome_cliente'],
}, },
async execute(args, ctx) { 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(12000),
body: JSON.stringify({
clinica_id: ctx.agenda.clinicaId, dentista_id: dentistaId, start, end,
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') const slot = await ctx.db('sec_calendar')
.where({ id: args['slot_id'], status: 'available' }) .where({ id: args['slot_id'], status: 'available' })
.first(); .first();
File diff suppressed because one or more lines are too long
@@ -38,6 +38,9 @@ export interface ToolContext {
extChatId?: string extChatId?: string
tenantId?: string tenantId?: string
hooks?: HookBus 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). */ /** Telemetria cumulativa preenchida pelos tool loops (M1.4). */
_telemetry?: { _telemetry?: {
usage: { input: number; output: number; total: number; cache_read?: number; cached?: number } 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) { 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 from = args['data'] ?? fmtDate(new Date())
const to = args['data'] ?? fmtDate(new Date(Date.now() + 7 * 86_400_000)) const to = args['data'] ?? fmtDate(new Date(Date.now() + 7 * 86_400_000))
const slots = await ctx.db('sec_calendar') const slots = await ctx.db('sec_calendar')
@@ -113,6 +145,45 @@ export const BUILTIN_TOOLS: ToolDef[] = [
required: ['slot_id', 'nome_cliente'], required: ['slot_id', 'nome_cliente'],
}, },
async execute(args, ctx) { 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') const slot = await ctx.db('sec_calendar')
.where({ id: args['slot_id'], status: 'available' }) .where({ id: args['slot_id'], status: 'available' })
.first() .first()