feat(secretaria): modelo de canal (clinica_id) + auto-reply 1:1 com nome legível

- sec_numbers ganha clinica_id (mapa canal→clínica); migrate idempotente.
- clinica_id resolvido por CANAL no auto-reply (sec_numbers pela instância que
  recebeu) e por-REQUISIÇÃO no chat manual (header x-nw-clinica); brain.chat
  aceita opts.clinicaId e escopa a ponte de agenda (fallback: config global).
- /secretaria/numbers CRUD inclui clinica_id.
- auto-reply só cria conversa para 1:1 (@s.whatsapp.net) — exclui grupos/
  broadcast/newsletter/lid; contact_name legível (Contact → +telefone → jid).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Deploy Agent
2026-07-02 05:20:28 +02:00
parent 9dbdd359ce
commit b4d43fc3d6
9 changed files with 119 additions and 29 deletions
@@ -103,8 +103,9 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
const { tenantId, instanceId, chatId, jid, text } = data; const { tenantId, instanceId, chatId, jid, text } = data;
if (!text?.trim()) if (!text?.trim())
return; return;
// NUNCA responde em grupos (@g.us): a Secretária é atendimento 1:1. // Só atendimento 1:1 (@s.whatsapp.net): exclui grupos (@g.us), broadcast,
if (jid.endsWith('@g.us')) // newsletter, lid e status — a Secretária não cria conversa para esses.
if (!jid.endsWith('@s.whatsapp.net'))
return; return;
// Kill-switch do auto-reply: secretaria.auto_reply === false desliga SÓ o // Kill-switch do auto-reply: secretaria.auto_reply === false desliga SÓ o
// disparo automático da Secretária (envios manuais seguem normais). Só // disparo automático da Secretária (envios manuais seguem normais). Só
@@ -122,10 +123,16 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
const agent = await db('sec_agents').where({ active: true }).orderBy('created_at').first(); const agent = await db('sec_agents').where({ active: true }).orderBy('created_at').first();
if (!agent) if (!agent)
return; return;
// Nome legível: Contact (agenda/WhatsApp) → telefone formatado → jid.
const contact = await prisma.contact.findFirst({
where: { jid, instanceId }, select: { name: true, verifiedName: true, notify: true },
});
const phone = jid.split('@')[0];
const displayName = contact?.name ?? contact?.verifiedName ?? contact?.notify ?? (phone ? `+${phone}` : jid);
const [newConv] = await db('sec_conversations').insert({ const [newConv] = await db('sec_conversations').insert({
id: uuid(), id: uuid(),
agent_id: agent.id, agent_id: agent.id,
contact_name: jid, contact_name: displayName,
protocol_number: brain_1.ProtocolEngine.generateProtocolNumber(), protocol_number: brain_1.ProtocolEngine.generateProtocolNumber(),
status: 'active', status: 'active',
ext_chat_id: extKey, ext_chat_id: extKey,
@@ -138,8 +145,11 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
// SEC-14: indicador de digitação antes de chamar a IA // SEC-14: indicador de digitação antes de chamar a IA
const sock = manager?.getSocket(instanceId); const sock = manager?.getSocket(instanceId);
await sock?.sendPresenceUpdate('composing', jid).catch(() => { }); await sock?.sendPresenceUpdate('composing', jid).catch(() => { });
// Modelo de canal: a clínica vem do NÚMERO que recebeu a mensagem
// (sec_numbers.clinica_id da instância) — escopo da ponte de agenda.
const channel = await db('sec_numbers').where({ instance_id: instanceId }).first();
const brain = new brain_1.ProtocolEngine(db, config); const brain = new brain_1.ProtocolEngine(db, config);
const reply = await brain.chat(conv.id, text.trim(), { tenantId, hooks }); const reply = await brain.chat(conv.id, text.trim(), { tenantId, hooks, clinicaId: channel?.clinica_id });
await sock?.sendPresenceUpdate('paused', jid).catch(() => { }); await sock?.sendPresenceUpdate('paused', jid).catch(() => { });
await sendSecretariaReply({ instanceId, tenantId, chatId, jid, reply }); await sendSecretariaReply({ instanceId, tenantId, chatId, jid, reply });
}); });
@@ -1036,8 +1046,9 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
try { try {
const b = req.body ?? {}; const b = req.body ?? {};
const [n] = await db('sec_numbers').insert({ const [n] = await db('sec_numbers').insert({
id: uuid(), instance_id: b.instance_id ?? null, phone: b.phone ?? null, label: b.label ?? null, id: uuid(), instance_id: b.instance_id ?? null, clinica_id: b.clinica_id ?? null,
role: b.role ?? null, area: b.area ?? null, priority: b.priority ?? 0, active: b.active ?? true, notes: b.notes ?? null, phone: b.phone ?? null, label: b.label ?? b.phone ?? 'Número',
role: b.role ?? 'clinic', area: b.area ?? null, priority: b.priority ?? 10, active: b.active ?? true, notes: b.notes ?? null,
}).returning('*'); }).returning('*');
res.status(201).json(n); res.status(201).json(n);
} }
@@ -1048,7 +1059,7 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
router.put('/secretaria/numbers/:id', async (req, res) => { router.put('/secretaria/numbers/:id', async (req, res) => {
try { try {
const b = req.body ?? {}, patch = { updated_at: new Date() }; const b = req.body ?? {}, patch = { updated_at: new Date() };
for (const k of ['instance_id', 'phone', 'label', 'role', 'area', 'priority', 'active', 'notes']) for (const k of ['instance_id', 'clinica_id', 'phone', 'label', 'role', 'area', 'priority', 'active', 'notes'])
if (k in b) if (k in b)
patch[k] = b[k]; patch[k] = b[k];
const [n] = await db('sec_numbers').where({ id: req.params['id'] }).update(patch).returning('*'); const [n] = await db('sec_numbers').where({ id: req.params['id'] }).update(patch).returning('*');
@@ -1134,8 +1145,10 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
res.status(400).json({ error: 'message obrigatório' }); res.status(400).json({ error: 'message obrigatório' });
return; return;
} }
// clínica por-requisição: workspace ativo do satélite (header x-nw-clinica).
const clinicaId = req.headers['x-nw-clinica']?.trim() || undefined;
const brain = new brain_1.ProtocolEngine(db, config); const brain = new brain_1.ProtocolEngine(db, config);
const reply = await brain.chat(req.params['id'], message, { tenantId: req.extTenantId, hooks }); const reply = await brain.chat(req.params['id'], message, { tenantId: req.extTenantId, hooks, clinicaId });
res.json({ reply }); res.json({ reply });
} }
catch (err) { catch (err) {
File diff suppressed because one or more lines are too long
@@ -120,8 +120,9 @@ export function buildExtRoutes(
} }
if (!text?.trim()) return if (!text?.trim()) return
// NUNCA responde em grupos (@g.us): a Secretária é atendimento 1:1. // Só atendimento 1:1 (@s.whatsapp.net): exclui grupos (@g.us), broadcast,
if (jid.endsWith('@g.us')) return // newsletter, lid e status — a Secretária não cria conversa para esses.
if (!jid.endsWith('@s.whatsapp.net')) return
// Kill-switch do auto-reply: secretaria.auto_reply === false desliga SÓ o // Kill-switch do auto-reply: secretaria.auto_reply === false desliga SÓ o
// disparo automático da Secretária (envios manuais seguem normais). Só // disparo automático da Secretária (envios manuais seguem normais). Só
@@ -139,10 +140,16 @@ export function buildExtRoutes(
if (!conv) { if (!conv) {
const agent = await db('sec_agents').where({ active: true }).orderBy('created_at').first() const agent = await db('sec_agents').where({ active: true }).orderBy('created_at').first()
if (!agent) return if (!agent) return
// Nome legível: Contact (agenda/WhatsApp) → telefone formatado → jid.
const contact = await prisma.contact.findFirst({
where: { jid, instanceId }, select: { name: true, verifiedName: true, notify: true },
})
const phone = jid.split('@')[0]
const displayName = contact?.name ?? contact?.verifiedName ?? contact?.notify ?? (phone ? `+${phone}` : jid)
const [newConv] = await db('sec_conversations').insert({ const [newConv] = await db('sec_conversations').insert({
id: uuid(), id: uuid(),
agent_id: agent.id, agent_id: agent.id,
contact_name: jid, contact_name: displayName,
protocol_number: ProtocolEngine.generateProtocolNumber(), protocol_number: ProtocolEngine.generateProtocolNumber(),
status: 'active', status: 'active',
ext_chat_id: extKey, ext_chat_id: extKey,
@@ -157,8 +164,11 @@ export function buildExtRoutes(
const sock = manager?.getSocket(instanceId) const sock = manager?.getSocket(instanceId)
await sock?.sendPresenceUpdate('composing', jid).catch(() => {}) await sock?.sendPresenceUpdate('composing', jid).catch(() => {})
// Modelo de canal: a clínica vem do NÚMERO que recebeu a mensagem
// (sec_numbers.clinica_id da instância) — escopo da ponte de agenda.
const channel = await db('sec_numbers').where({ instance_id: instanceId }).first()
const brain = new ProtocolEngine(db, config) const brain = new ProtocolEngine(db, config)
const reply = await brain.chat(conv.id as string, text.trim(), { tenantId, hooks }) const reply = await brain.chat(conv.id as string, text.trim(), { tenantId, hooks, clinicaId: channel?.clinica_id })
await sock?.sendPresenceUpdate('paused', jid).catch(() => {}) await sock?.sendPresenceUpdate('paused', jid).catch(() => {})
await sendSecretariaReply({ instanceId, tenantId, chatId, jid, reply }) await sendSecretariaReply({ instanceId, tenantId, chatId, jid, reply })
@@ -1037,8 +1047,9 @@ export function buildExtRoutes(
try { try {
const b = req.body ?? {} const b = req.body ?? {}
const [n] = await db('sec_numbers').insert({ const [n] = await db('sec_numbers').insert({
id: uuid(), instance_id: b.instance_id ?? null, phone: b.phone ?? null, label: b.label ?? null, id: uuid(), instance_id: b.instance_id ?? null, clinica_id: b.clinica_id ?? null,
role: b.role ?? null, area: b.area ?? null, priority: b.priority ?? 0, active: b.active ?? true, notes: b.notes ?? null, phone: b.phone ?? null, label: b.label ?? b.phone ?? 'Número',
role: b.role ?? 'clinic', area: b.area ?? null, priority: b.priority ?? 10, active: b.active ?? true, notes: b.notes ?? null,
}).returning('*') }).returning('*')
res.status(201).json(n) res.status(201).json(n)
} catch (err: any) { res.status(500).json({ error: err.message }) } } catch (err: any) { res.status(500).json({ error: err.message }) }
@@ -1046,7 +1057,7 @@ export function buildExtRoutes(
router.put('/secretaria/numbers/:id', async (req: Request, res: Response) => { router.put('/secretaria/numbers/:id', async (req: Request, res: Response) => {
try { try {
const b = req.body ?? {}, patch: any = { updated_at: new Date() } const b = req.body ?? {}, patch: any = { updated_at: new Date() }
for (const k of ['instance_id', 'phone', 'label', 'role', 'area', 'priority', 'active', 'notes']) if (k in b) patch[k] = b[k] for (const k of ['instance_id', 'clinica_id', 'phone', 'label', 'role', 'area', 'priority', 'active', 'notes']) if (k in b) patch[k] = b[k]
const [n] = await db('sec_numbers').where({ id: req.params['id'] }).update(patch).returning('*') const [n] = await db('sec_numbers').where({ id: req.params['id'] }).update(patch).returning('*')
res.json(n) res.json(n)
} catch (err: any) { res.status(500).json({ error: err.message }) } } catch (err: any) { res.status(500).json({ error: err.message }) }
@@ -1101,8 +1112,10 @@ export function buildExtRoutes(
try { try {
const message = String((req.body ?? {}).message ?? '').trim() const message = String((req.body ?? {}).message ?? '').trim()
if (!message) { res.status(400).json({ error: 'message obrigatório' }); return } if (!message) { res.status(400).json({ error: 'message obrigatório' }); return }
// clínica por-requisição: workspace ativo do satélite (header x-nw-clinica).
const clinicaId = (req.headers['x-nw-clinica'] as string | undefined)?.trim() || undefined
const brain = new ProtocolEngine(db, config) const brain = new ProtocolEngine(db, config)
const reply = await brain.chat(req.params['id']!, message, { tenantId: req.extTenantId, hooks }) const reply = await brain.chat(req.params['id']!, message, { tenantId: req.extTenantId, hooks, clinicaId })
res.json({ reply }) res.json({ reply })
} catch (err: any) { res.status(500).json({ error: err.message }) } } catch (err: any) { res.status(500).json({ error: err.message }) }
}) })
@@ -58,18 +58,46 @@ class ProtocolEngine {
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 secCfg = (await this.config.get('secretaria'));
// Callback da ponte de agenda: derivado do REGISTRO do satélite (fonte de
// verdade), com fallback para a config global:
// - URL: clientUrl do PluginPair ATIVO do tenant (o mais recentemente visto,
// desambiguando quando há vários pares) + /api/nw/agenda;
// - segredo: reusa o ExtWebhook.secret ativo do tenant (mesmo segredo já
// compartilhado motor↔satélite);
// - clinica_id: ainda vem da config global (o escopo por-clínica depende do
// modelo de canal/workspace, fora deste passo).
let agendaUrl = secCfg?.agenda_url;
let agendaSecret = secCfg?.agenda_secret;
if (opts?.tenantId) {
try {
const pair = await this.db('plugin_pairs')
.where({ userId: opts.tenantId, revokedAt: null })
.whereNotNull('clientUrl')
.orderByRaw('"lastSeenAt" DESC NULLS LAST, "createdAt" DESC')
.first();
if (pair?.clientUrl && /^https?:\/\//.test(pair.clientUrl)) {
agendaUrl = `${String(pair.clientUrl).replace(/\/$/, '')}/api/nw/agenda`;
}
const wh = await this.db('ext_webhooks')
.where({ tenantId: opts.tenantId, active: true })
.orderBy('createdAt', 'desc')
.first();
if (wh?.secret)
agendaSecret = wh.secret;
}
catch { /* fallback: config global */ }
}
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: { agenda: {
url: secCfg?.agenda_url, url: agendaUrl,
secret: secCfg?.agenda_secret, secret: agendaSecret,
clinicaId: secCfg?.clinica_id, // clínica: do canal/requisição (opts) → fallback config global.
clinicaId: opts?.clinicaId ?? secCfg?.clinica_id,
}, },
}; };
let response; let response;
File diff suppressed because one or more lines are too long
@@ -31,6 +31,7 @@ export class ProtocolEngine {
tools?: string[] // nomes das tools a habilitar (padrão: todas) tools?: string[] // nomes das tools a habilitar (padrão: todas)
hooks?: HookBus hooks?: HookBus
tenantId?: string tenantId?: string
clinicaId?: string // clínica do canal/workspace (escopo da ponte de agenda)
}, },
): Promise<string> { ): Promise<string> {
const conversation = await this.db('sec_conversations').where({ id: conversationId }).first() const conversation = await this.db('sec_conversations').where({ id: conversationId }).first()
@@ -79,18 +80,44 @@ export class ProtocolEngine {
const toolDefs = resolveTools(toolNames) const toolDefs = resolveTools(toolNames)
const secCfg = (await this.config.get('secretaria')) as any const secCfg = (await this.config.get('secretaria')) as any
// Callback da ponte de agenda: derivado do REGISTRO do satélite (fonte de
// verdade), com fallback para a config global:
// - URL: clientUrl do PluginPair ATIVO do tenant (o mais recentemente visto,
// desambiguando quando há vários pares) + /api/nw/agenda;
// - segredo: reusa o ExtWebhook.secret ativo do tenant (mesmo segredo já
// compartilhado motor↔satélite);
// - clinica_id: ainda vem da config global (o escopo por-clínica depende do
// modelo de canal/workspace, fora deste passo).
let agendaUrl: string | undefined = secCfg?.agenda_url
let agendaSecret: string | undefined = secCfg?.agenda_secret
if (opts?.tenantId) {
try {
const pair = await this.db('plugin_pairs')
.where({ userId: opts.tenantId, revokedAt: null })
.whereNotNull('clientUrl')
.orderByRaw('"lastSeenAt" DESC NULLS LAST, "createdAt" DESC')
.first()
if (pair?.clientUrl && /^https?:\/\//.test(pair.clientUrl)) {
agendaUrl = `${String(pair.clientUrl).replace(/\/$/, '')}/api/nw/agenda`
}
const wh = await this.db('ext_webhooks')
.where({ tenantId: opts.tenantId, active: true })
.orderBy('createdAt', 'desc')
.first()
if (wh?.secret) agendaSecret = wh.secret
} catch { /* fallback: config global */ }
}
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: { agenda: {
url: secCfg?.agenda_url, url: agendaUrl,
secret: secCfg?.agenda_secret, secret: agendaSecret,
clinicaId: secCfg?.clinica_id, // clínica: do canal/requisição (opts) → fallback config global.
clinicaId: opts?.clinicaId ?? secCfg?.clinica_id,
}, },
} }
@@ -127,6 +127,7 @@ async function runMigrations(db) {
await db.schema.createTable('sec_numbers', (t) => { await db.schema.createTable('sec_numbers', (t) => {
t.uuid('id').primary().defaultTo(db.raw('gen_random_uuid()')); t.uuid('id').primary().defaultTo(db.raw('gen_random_uuid()'));
t.string('instance_id', 100).nullable(); // ID da instância Baileys existente t.string('instance_id', 100).nullable(); // ID da instância Baileys existente
t.string('clinica_id', 100).nullable(); // canal→clínica: workspace dono deste número (satélite)
t.string('phone', 30).nullable(); // número do WhatsApp (preenchido após conexão) t.string('phone', 30).nullable(); // número do WhatsApp (preenchido após conexão)
t.string('label', 100).notNullable(); // apelido (pode vir do nome da instância) t.string('label', 100).notNullable(); // apelido (pode vir do nome da instância)
t.string('role', 30).notNullable().defaultTo('clinic'); // secretary_virtual | clinic | doctor | specialist | manager | reserve | human_secretary t.string('role', 30).notNullable().defaultTo('clinic'); // secretary_virtual | clinic | doctor | specialist | manager | reserve | human_secretary
@@ -137,6 +138,10 @@ async function runMigrations(db) {
t.timestamps(true, true); t.timestamps(true, true);
}); });
} }
else if (!(await db.schema.hasColumn('sec_numbers', 'clinica_id'))) {
// Tabela pré-existente: adiciona o mapa canal→clínica.
await db.schema.alterTable('sec_numbers', (t) => t.string('clinica_id', 100).nullable());
}
// ── sec_knowledge_chunks (RAG sem pgvector) ─────────────────────────────── // ── sec_knowledge_chunks (RAG sem pgvector) ───────────────────────────────
// Chunks de conhecimento + embedding (JSON em text). Busca por similaridade // Chunks de conhecimento + embedding (JSON em text). Busca por similaridade
// roda na aplicação (cosseno), então não exige a extensão pgvector. // roda na aplicação (cosseno), então não exige a extensão pgvector.
File diff suppressed because one or more lines are too long
@@ -136,6 +136,7 @@ export async function runMigrations(db: Knex): Promise<void> {
await db.schema.createTable('sec_numbers', (t) => { await db.schema.createTable('sec_numbers', (t) => {
t.uuid('id').primary().defaultTo(db.raw('gen_random_uuid()')) t.uuid('id').primary().defaultTo(db.raw('gen_random_uuid()'))
t.string('instance_id', 100).nullable() // ID da instância Baileys existente t.string('instance_id', 100).nullable() // ID da instância Baileys existente
t.string('clinica_id', 100).nullable() // canal→clínica: workspace dono deste número (satélite)
t.string('phone', 30).nullable() // número do WhatsApp (preenchido após conexão) t.string('phone', 30).nullable() // número do WhatsApp (preenchido após conexão)
t.string('label', 100).notNullable() // apelido (pode vir do nome da instância) t.string('label', 100).notNullable() // apelido (pode vir do nome da instância)
t.string('role', 30).notNullable().defaultTo('clinic') // secretary_virtual | clinic | doctor | specialist | manager | reserve | human_secretary t.string('role', 30).notNullable().defaultTo('clinic') // secretary_virtual | clinic | doctor | specialist | manager | reserve | human_secretary
@@ -145,6 +146,9 @@ export async function runMigrations(db: Knex): Promise<void> {
t.text('notes').nullable() t.text('notes').nullable()
t.timestamps(true, true) t.timestamps(true, true)
}) })
} else if (!(await db.schema.hasColumn('sec_numbers', 'clinica_id'))) {
// Tabela pré-existente: adiciona o mapa canal→clínica.
await db.schema.alterTable('sec_numbers', (t) => t.string('clinica_id', 100).nullable())
} }
// ── sec_knowledge_chunks (RAG sem pgvector) ─────────────────────────────── // ── sec_knowledge_chunks (RAG sem pgvector) ───────────────────────────────