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:
@@ -58,18 +58,46 @@ class ProtocolEngine {
|
||||
const toolNames = opts?.tools ?? tools_1.ALL_TOOL_NAMES;
|
||||
const toolDefs = (0, tools_1.resolveTools)(toolNames);
|
||||
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 = {
|
||||
db: this.db,
|
||||
conversationId,
|
||||
extChatId: conversation.ext_chat_id ?? undefined,
|
||||
tenantId: opts?.tenantId,
|
||||
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,
|
||||
url: agendaUrl,
|
||||
secret: agendaSecret,
|
||||
// clínica: do canal/requisição (opts) → fallback config global.
|
||||
clinicaId: opts?.clinicaId ?? secCfg?.clinica_id,
|
||||
},
|
||||
};
|
||||
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)
|
||||
hooks?: HookBus
|
||||
tenantId?: string
|
||||
clinicaId?: string // clínica do canal/workspace (escopo da ponte de agenda)
|
||||
},
|
||||
): Promise<string> {
|
||||
const conversation = await this.db('sec_conversations').where({ id: conversationId }).first()
|
||||
@@ -79,18 +80,44 @@ export class ProtocolEngine {
|
||||
const toolDefs = resolveTools(toolNames)
|
||||
|
||||
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 = {
|
||||
db: this.db,
|
||||
conversationId,
|
||||
extChatId: conversation.ext_chat_id ?? undefined,
|
||||
tenantId: opts?.tenantId,
|
||||
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,
|
||||
url: agendaUrl,
|
||||
secret: agendaSecret,
|
||||
// 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) => {
|
||||
t.uuid('id').primary().defaultTo(db.raw('gen_random_uuid()'));
|
||||
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('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
|
||||
@@ -137,6 +138,10 @@ async function runMigrations(db) {
|
||||
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) ───────────────────────────────
|
||||
// Chunks de conhecimento + embedding (JSON em text). Busca por similaridade
|
||||
// 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) => {
|
||||
t.uuid('id').primary().defaultTo(db.raw('gen_random_uuid()'))
|
||||
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('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
|
||||
@@ -145,6 +146,9 @@ export async function runMigrations(db: Knex): Promise<void> {
|
||||
t.text('notes').nullable()
|
||||
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) ───────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user