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;
|
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 })
|
||||||
|
|||||||
Reference in New Issue
Block a user