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:
@@ -103,6 +103,9 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
|
||||
const { tenantId, instanceId, chatId, jid, text } = data;
|
||||
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
|
||||
const activeBot = await prisma.aIBot.findFirst({ where: { tenantId, instanceId, enabled: true } });
|
||||
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,
|
||||
select: {
|
||||
id: true,
|
||||
@@ -407,6 +412,60 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
|
||||
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 ───────────────────────────────────────────
|
||||
// Histórico paginado (cursor: before=<timestamp ISO>).
|
||||
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)
|
||||
router.get('/sec/keys', async (_req, res) => {
|
||||
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 result = {};
|
||||
for (const k of secretKeys) {
|
||||
const v = await config.get(k);
|
||||
const raw = typeof v === 'string' ? v : '';
|
||||
const raw = typeof cfg[k] === 'string' ? cfg[k] : '';
|
||||
result[k] = raw.length > 0
|
||||
? (raw.length > 8 ? raw.slice(0, 8) + '****' : '****')
|
||||
: '';
|
||||
}
|
||||
// Número de notificação: retorna em claro
|
||||
const notifyRaw = await config.get('admin_notify_phone');
|
||||
result['admin_notify_phone'] = typeof notifyRaw === 'string' ? notifyRaw : '';
|
||||
result['admin_notify_phone'] = typeof cfg['admin_notify_phone'] === 'string' ? cfg['admin_notify_phone'] : '';
|
||||
res.json(result);
|
||||
}
|
||||
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 ****)
|
||||
router.put('/sec/keys', async (req, res) => {
|
||||
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'];
|
||||
for (const k of secretKeys) {
|
||||
const v = req.body[k];
|
||||
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)
|
||||
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 });
|
||||
}
|
||||
catch (err) {
|
||||
|
||||
Reference in New Issue
Block a user