feat(ext-api): inbox ordenado, avatar self-heal, /sec/keys e guard de grupo

- GET /inbox: orderBy lastMessageAt DESC NULLS LAST (Postgres põe NULLS FIRST
  em DESC → chats-stub sem mensagem tomavam o topo; parecia lista de contatos).
- GET /contacts/:jid/avatar/refresh: re-busca a foto pela conexão Baileys viva
  (URLs do CDN expiram/404), com timeout de 5s e limpeza da URL obsoleta.
- GET/PUT /sec/keys: passam a ler/gravar na config do plugin 'secretaria'
  (config é por-plugin: set(pluginName, obj)); antes gravava em plugin errado
  e o brain nunca via as chaves. Corrige TS2345.
- auto-reply da Secretária: nunca responde em grupos (@g.us).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Deploy Agent
2026-07-01 20:55:22 +02:00
parent dbb4da8220
commit a1eec8dac2
3 changed files with 148 additions and 15 deletions
@@ -103,6 +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.
if (jid.endsWith('@g.us'))
return;
// Exclusão mútua: chatbot rápido tem prioridade se estiver ativo // Exclusão mútua: chatbot rápido tem prioridade se estiver ativo
const activeBot = await prisma.aIBot.findFirst({ where: { tenantId, instanceId, enabled: true } }); const activeBot = await prisma.aIBot.findFirst({ where: { tenantId, instanceId, enabled: true } });
if (activeBot) if (activeBot)
@@ -355,7 +358,9 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
] } ] }
: {}), : {}),
}, },
orderBy: { lastMessageAt: 'desc' }, // nulls: 'last' — sem isto o Postgres ordena NULLS FIRST em DESC, e os
// chats-stub sem mensagem (lastMessageAt null) tomam o topo do inbox.
orderBy: { lastMessageAt: { sort: 'desc', nulls: 'last' } },
take: limit, take: limit,
select: { select: {
id: true, id: true,
@@ -407,6 +412,60 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
res.status(500).json({ error: err.message ?? 'Erro interno' }); res.status(500).json({ error: err.message ?? 'Erro interno' });
} }
}); });
// ── GET /contacts/:jid/avatar/refresh ─────────────────────────────────────
// Self-heal de avatar: a URL do CDN do WhatsApp (pps.whatsapp.net) expira
// (param oe=) e passa a 404. Aqui re-buscamos a foto ATUAL pela conexão
// Baileys viva, atualizamos o Contact.avatarUrl e devolvemos a URL nova.
// Chamado pelo componente Avatar do satélite no onError da imagem.
router.get('/contacts/:jid/avatar/refresh', async (req, res) => {
const tenantId = req.extTenantId;
const sessionId = req.query['session'];
const jid = decodeURIComponent(req.params['jid'] ?? '');
if (!sessionId || !jid) {
res.status(400).json({ error: 'Query "session" e param "jid" obrigatórios' });
return;
}
const instance = await prisma.instance.findFirst({
where: { id: sessionId, tenantId },
select: { id: true },
});
if (!instance) {
res.status(404).json({ error: 'Instância não encontrada' });
return;
}
const sock = manager?.getSocket(sessionId);
if (!sock) {
res.status(503).json({ error: 'Sessão não conectada' });
return;
}
try {
// profilePictureUrl pode TRAVAR (contato com foto restrita/privacidade ou
// sem resposta do WhatsApp). Corremos contra um timeout para responder
// rápido (404 → cai nas iniciais) e nunca estourar 504 no gateway.
const url = await Promise.race([
sock.profilePictureUrl(jid, 'image').catch(() => null),
new Promise((resolve) => setTimeout(() => resolve(null), 5000)),
]);
if (!url) {
// Sem foto acessível → limpa a URL obsoleta do banco para não servir de
// novo a URL 404 (futuras cargas vêm avatar=null → iniciais na hora).
await prisma.contact.updateMany({
where: { tenantId, instanceId: sessionId, jid, avatarUrl: { not: null } },
data: { avatarUrl: null },
});
res.status(404).json({ avatar: null });
return;
}
await prisma.contact.updateMany({
where: { tenantId, instanceId: sessionId, jid },
data: { avatarUrl: url },
});
res.json({ avatar: url });
}
catch (err) {
res.status(502).json({ error: err.message ?? 'Falha ao buscar avatar' });
}
});
// ── GET /inbox/:chatId/messages ─────────────────────────────────────────── // ── GET /inbox/:chatId/messages ───────────────────────────────────────────
// Histórico paginado (cursor: before=<timestamp ISO>). // Histórico paginado (cursor: before=<timestamp ISO>).
router.get('/inbox/:chatId/messages', async (req, res) => { router.get('/inbox/:chatId/messages', async (req, res) => {
@@ -1047,18 +1106,19 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
// admin_notify_phone é retornado sem máscara (não é segredo) // admin_notify_phone é retornado sem máscara (não é segredo)
router.get('/sec/keys', async (_req, res) => { router.get('/sec/keys', async (_req, res) => {
try { try {
// As chaves vivem DENTRO da config do plugin 'secretaria' (é o que o
// brain lê via config.get('secretaria')). config é por-plugin, não por-chave.
const cfg = ((await config.get('secretaria')) ?? {});
const secretKeys = ['openai_key', 'gemini_key', 'anthropic_key', 'ollama_url']; const secretKeys = ['openai_key', 'gemini_key', 'anthropic_key', 'ollama_url'];
const result = {}; const result = {};
for (const k of secretKeys) { for (const k of secretKeys) {
const v = await config.get(k); const raw = typeof cfg[k] === 'string' ? cfg[k] : '';
const raw = typeof v === 'string' ? v : '';
result[k] = raw.length > 0 result[k] = raw.length > 0
? (raw.length > 8 ? raw.slice(0, 8) + '****' : '****') ? (raw.length > 8 ? raw.slice(0, 8) + '****' : '****')
: ''; : '';
} }
// Número de notificação: retorna em claro // Número de notificação: retorna em claro
const notifyRaw = await config.get('admin_notify_phone'); result['admin_notify_phone'] = typeof cfg['admin_notify_phone'] === 'string' ? cfg['admin_notify_phone'] : '';
result['admin_notify_phone'] = typeof notifyRaw === 'string' ? notifyRaw : '';
res.json(result); res.json(result);
} }
catch (err) { catch (err) {
@@ -1069,17 +1129,22 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
// Só persiste keys que não são máscara (não terminam em ****) // Só persiste keys que não são máscara (não terminam em ****)
router.put('/sec/keys', async (req, res) => { router.put('/sec/keys', async (req, res) => {
try { try {
// Lê a config atual do plugin 'secretaria', mescla as chaves novas e
// regrava o objeto INTEIRO (config.set(pluginName, obj)) — antes gravava
// cada chave como se fosse um plugin próprio, e o brain nunca as via.
const cfg = { ...((await config.get('secretaria')) ?? {}) };
const secretKeys = ['openai_key', 'gemini_key', 'anthropic_key', 'ollama_url']; const secretKeys = ['openai_key', 'gemini_key', 'anthropic_key', 'ollama_url'];
for (const k of secretKeys) { for (const k of secretKeys) {
const v = req.body[k]; const v = req.body[k];
if (typeof v === 'string' && v.length > 0 && !v.endsWith('****')) { if (typeof v === 'string' && v.length > 0 && !v.endsWith('****')) {
await config.set(k, v); cfg[k] = v;
} }
} }
// admin_notify_phone: sempre persiste (pode ser string vazia para limpar) // admin_notify_phone: sempre persiste (pode ser string vazia para limpar)
if (typeof req.body['admin_notify_phone'] === 'string') { if (typeof req.body['admin_notify_phone'] === 'string') {
await config.set('admin_notify_phone', req.body['admin_notify_phone'].trim()); cfg['admin_notify_phone'] = req.body['admin_notify_phone'].trim();
} }
await config.set('secretaria', cfg);
res.json({ ok: true }); res.json({ ok: true });
} }
catch (err) { catch (err) {
File diff suppressed because one or more lines are too long
@@ -120,6 +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.
if (jid.endsWith('@g.us')) return
// Exclusão mútua: chatbot rápido tem prioridade se estiver ativo // Exclusão mútua: chatbot rápido tem prioridade se estiver ativo
const activeBot = await prisma.aIBot.findFirst({ where: { tenantId, instanceId, enabled: true } }) const activeBot = await prisma.aIBot.findFirst({ where: { tenantId, instanceId, enabled: true } })
if (activeBot) return if (activeBot) return
@@ -385,7 +388,9 @@ export function buildExtRoutes(
] } ] }
: {}), : {}),
}, },
orderBy: { lastMessageAt: 'desc' }, // nulls: 'last' — sem isto o Postgres ordena NULLS FIRST em DESC, e os
// chats-stub sem mensagem (lastMessageAt null) tomam o topo do inbox.
orderBy: { lastMessageAt: { sort: 'desc', nulls: 'last' } },
take: limit, take: limit,
select: { select: {
id: true, id: true,
@@ -439,6 +444,63 @@ export function buildExtRoutes(
} }
}) })
// ── GET /contacts/:jid/avatar/refresh ─────────────────────────────────────
// Self-heal de avatar: a URL do CDN do WhatsApp (pps.whatsapp.net) expira
// (param oe=) e passa a 404. Aqui re-buscamos a foto ATUAL pela conexão
// Baileys viva, atualizamos o Contact.avatarUrl e devolvemos a URL nova.
// Chamado pelo componente Avatar do satélite no onError da imagem.
router.get('/contacts/:jid/avatar/refresh', async (req: Request, res: Response) => {
const tenantId = req.extTenantId!
const sessionId = req.query['session'] as string | undefined
const jid = decodeURIComponent(req.params['jid'] ?? '')
if (!sessionId || !jid) {
res.status(400).json({ error: 'Query "session" e param "jid" obrigatórios' })
return
}
const instance = await prisma.instance.findFirst({
where: { id: sessionId, tenantId },
select: { id: true },
})
if (!instance) {
res.status(404).json({ error: 'Instância não encontrada' })
return
}
const sock = manager?.getSocket(sessionId)
if (!sock) {
res.status(503).json({ error: 'Sessão não conectada' })
return
}
try {
// profilePictureUrl pode TRAVAR (contato com foto restrita/privacidade ou
// sem resposta do WhatsApp). Corremos contra um timeout para responder
// rápido (404 → cai nas iniciais) e nunca estourar 504 no gateway.
const url = await Promise.race([
sock.profilePictureUrl(jid, 'image').catch(() => null),
new Promise<null>((resolve) => setTimeout(() => resolve(null), 5000)),
])
if (!url) {
// Sem foto acessível → limpa a URL obsoleta do banco para não servir de
// novo a URL 404 (futuras cargas vêm avatar=null → iniciais na hora).
await prisma.contact.updateMany({
where: { tenantId, instanceId: sessionId, jid, avatarUrl: { not: null } },
data: { avatarUrl: null },
})
res.status(404).json({ avatar: null })
return
}
await prisma.contact.updateMany({
where: { tenantId, instanceId: sessionId, jid },
data: { avatarUrl: url },
})
res.json({ avatar: url })
} catch (err: any) {
res.status(502).json({ error: err.message ?? 'Falha ao buscar avatar' })
}
})
// ── GET /inbox/:chatId/messages ─────────────────────────────────────────── // ── GET /inbox/:chatId/messages ───────────────────────────────────────────
// Histórico paginado (cursor: before=<timestamp ISO>). // Histórico paginado (cursor: before=<timestamp ISO>).
router.get('/inbox/:chatId/messages', async (req: Request, res: Response) => { router.get('/inbox/:chatId/messages', async (req: Request, res: Response) => {
@@ -1086,18 +1148,19 @@ export function buildExtRoutes(
// admin_notify_phone é retornado sem máscara (não é segredo) // admin_notify_phone é retornado sem máscara (não é segredo)
router.get('/sec/keys', async (_req: Request, res: Response) => { router.get('/sec/keys', async (_req: Request, res: Response) => {
try { try {
// As chaves vivem DENTRO da config do plugin 'secretaria' (é o que o
// brain lê via config.get('secretaria')). config é por-plugin, não por-chave.
const cfg = ((await config.get('secretaria')) ?? {}) as Record<string, any>
const secretKeys = ['openai_key', 'gemini_key', 'anthropic_key', 'ollama_url'] const secretKeys = ['openai_key', 'gemini_key', 'anthropic_key', 'ollama_url']
const result: Record<string, string> = {} const result: Record<string, string> = {}
for (const k of secretKeys) { for (const k of secretKeys) {
const v = await config.get(k) const raw = typeof cfg[k] === 'string' ? cfg[k] as string : ''
const raw = typeof v === 'string' ? v : ''
result[k] = raw.length > 0 result[k] = raw.length > 0
? (raw.length > 8 ? raw.slice(0, 8) + '****' : '****') ? (raw.length > 8 ? raw.slice(0, 8) + '****' : '****')
: '' : ''
} }
// Número de notificação: retorna em claro // Número de notificação: retorna em claro
const notifyRaw = await config.get('admin_notify_phone') result['admin_notify_phone'] = typeof cfg['admin_notify_phone'] === 'string' ? cfg['admin_notify_phone'] : ''
result['admin_notify_phone'] = typeof notifyRaw === 'string' ? notifyRaw : ''
res.json(result) res.json(result)
} catch (err: any) { } catch (err: any) {
res.status(500).json({ error: err.message }) res.status(500).json({ error: err.message })
@@ -1108,17 +1171,22 @@ export function buildExtRoutes(
// Só persiste keys que não são máscara (não terminam em ****) // Só persiste keys que não são máscara (não terminam em ****)
router.put('/sec/keys', async (req: Request, res: Response) => { router.put('/sec/keys', async (req: Request, res: Response) => {
try { try {
// Lê a config atual do plugin 'secretaria', mescla as chaves novas e
// regrava o objeto INTEIRO (config.set(pluginName, obj)) — antes gravava
// cada chave como se fosse um plugin próprio, e o brain nunca as via.
const cfg = { ...(((await config.get('secretaria')) ?? {}) as Record<string, any>) }
const secretKeys = ['openai_key', 'gemini_key', 'anthropic_key', 'ollama_url'] const secretKeys = ['openai_key', 'gemini_key', 'anthropic_key', 'ollama_url']
for (const k of secretKeys) { for (const k of secretKeys) {
const v = req.body[k] const v = req.body[k]
if (typeof v === 'string' && v.length > 0 && !v.endsWith('****')) { if (typeof v === 'string' && v.length > 0 && !v.endsWith('****')) {
await config.set(k, v) cfg[k] = v
} }
} }
// admin_notify_phone: sempre persiste (pode ser string vazia para limpar) // admin_notify_phone: sempre persiste (pode ser string vazia para limpar)
if (typeof req.body['admin_notify_phone'] === 'string') { if (typeof req.body['admin_notify_phone'] === 'string') {
await config.set('admin_notify_phone', req.body['admin_notify_phone'].trim()) cfg['admin_notify_phone'] = req.body['admin_notify_phone'].trim()
} }
await config.set('secretaria', cfg)
res.json({ ok: true }) res.json({ ok: true })
} catch (err: any) { } catch (err: any) {
res.status(500).json({ error: err.message }) res.status(500).json({ error: err.message })