feat: sistema de figurinhas, emoji PT-BR e preloader inteligente

- Stickers: captura automática por fileSha256 (Baileys) no MessageHandler,
  tabela Prisma 'stickers' com deduplicação por [tenantId, sha256],
  API REST /api/stickers (list + favorite toggle), cache IndexedDB permanente
  (sem TTL — conteúdo imutável), StickerPanel.tsx com abas Recentes/Favoritos
- Emoji picker: migrado de emoji-picker-react para @emoji-mart/react com
  locale="pt" (tradução completa: categorias, busca, mensagem de sem resultado)
- Preloader: aparece apenas em hard reload (F5/URL direta) via
  performance.getEntriesByType('navigation').type + sessionStorage flag,
  navegação client-side Next.js pula o overlay instantaneamente
- Avatar cache: Wasabi como storage persistente + IndexedDB client-side,
  avatar_version via MD5(avatarUrl) sem coluna extra no banco,
  rota /api/storage/view/* para servir arquivos Wasabi ao frontend

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Deploy Agent
2026-05-11 17:48:36 +02:00
parent 2b9b855fc9
commit b97f7f94e4
33 changed files with 1183 additions and 128 deletions
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
@@ -533,6 +533,27 @@ enum ScheduleStatus {
DONE DONE
CANCELLED CANCELLED
} }
// ─── FIGURINHAS (STICKERS) ────────────────────────────────────────────────────
// Armazenamento persistente de stickers usando fileSha256 do protocolo Baileys
// como chave de conteúdo. Garante deduplicação mesmo após reinstalação do WA.
model Sticker {
id String @id @default(uuid())
tenantId String
instanceId String
sha256 String // fileSha256 hex — identidade permanente do conteúdo
wasabiPath String // stickers/{tenantId}/{sha256}.webp
isAnimated Boolean @default(false)
isFavorite Boolean @default(false)
useCount Int @default(1)
lastSeenAt DateTime @default(now())
createdAt DateTime @default(now())
@@unique([tenantId, sha256])
@@index([tenantId, instanceId, isFavorite, lastSeenAt(sort: Desc)])
@@map("stickers")
}
// Webhook allowed host fix // Webhook allowed host fix
// Webhook allowed host fix // Webhook allowed host fix
// Webhook query string fix // Webhook query string fix
@@ -11,9 +11,16 @@ class StorageProviderRegistry {
constructor() { constructor() {
this.provider = null; this.provider = null;
this.healthy = false; this.healthy = false;
this.lastHealthStatus = {
healthy: false,
configured: false,
reason: 'not_configured',
message: 'Storage não inicializado.',
};
} }
register(provider) { register(provider) {
this.provider = provider; this.provider = provider;
this.lastHealthStatus = { healthy: true, configured: true, reason: 'ok', message: 'Wasabi inicializado.' };
logger_1.logger.info('[StorageProvider] Provider registrado'); logger_1.logger.info('[StorageProvider] Provider registrado');
} }
isRegistered() { isRegistered() {
@@ -22,6 +29,13 @@ class StorageProviderRegistry {
isHealthy() { isHealthy() {
return this.healthy; return this.healthy;
} }
getLastHealthStatus() {
return this.lastHealthStatus;
}
setHealthStatus(status) {
this.lastHealthStatus = status;
this.healthy = status.healthy;
}
async uploadFile(payload) { async uploadFile(payload) {
if (!this.provider) { if (!this.provider) {
throw new Error('Nenhum storage provider registrado. Ative o plugin UnifiedStorageProvider.'); throw new Error('Nenhum storage provider registrado. Ative o plugin UnifiedStorageProvider.');
@@ -32,15 +46,34 @@ class StorageProviderRegistry {
async updateHealth() { async updateHealth() {
if (!this.provider) { if (!this.provider) {
this.healthy = false; this.healthy = false;
this.lastHealthStatus = {
healthy: false,
configured: false,
reason: 'not_configured',
message: 'Storage não configurado. Acesse Admin > Plugins > UnifiedStorageProvider.',
};
return; return;
} }
try { try {
this.healthy = await this.provider.checkHealth(); const status = await this.provider.checkDetailedHealth();
this.healthy = status.healthy;
this.lastHealthStatus = status;
} }
catch { catch {
this.healthy = false; this.healthy = false;
this.lastHealthStatus = {
healthy: false,
configured: true,
reason: 'unknown',
message: 'Erro desconhecido ao verificar o storage.',
};
} }
} }
async getBuffer(path) {
if (!this.provider)
return null;
return this.provider.getBuffer(path);
}
async deletePrefix(prefix) { async deletePrefix(prefix) {
if (!this.provider) { if (!this.provider) {
logger_1.logger.warn('[StorageProvider] deletePrefix chamado mas nenhum provider está registrado'); logger_1.logger.warn('[StorageProvider] deletePrefix chamado mas nenhum provider está registrado');
@@ -1 +1 @@
{"version":3,"file":"StorageProvider.js","sourceRoot":"","sources":["StorageProvider.ts"],"names":[],"mappings":";;;AAAA,6CAAuD;AAGvD;;;;GAIG;AACH,MAAM,uBAAuB;IAA7B;QACU,aAAQ,GAAoC,IAAI,CAAA;QAChD,YAAO,GAAG,KAAK,CAAA;IAgDzB,CAAC;IA9CC,QAAQ,CAAC,QAAkC;QACzC,IAAI,CAAC,QAAQ,GAAG,QAAQ,CAAA;QACxB,eAAU,CAAC,IAAI,CAAC,uCAAuC,CAAC,CAAA;IAC1D,CAAC;IAED,YAAY;QACV,OAAO,IAAI,CAAC,QAAQ,KAAK,IAAI,CAAA;IAC/B,CAAC;IAED,SAAS;QACP,OAAO,IAAI,CAAC,OAAO,CAAA;IACrB,CAAC;IAED,KAAK,CAAC,UAAU,CAAC,OAA6B;QAC5C,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE,CAAC;YACnB,MAAM,IAAI,KAAK,CAAC,4EAA4E,CAAC,CAAA;QAC/F,CAAC;QACD,OAAO,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,OAAO,CAAC,CAAA;IACtC,CAAC;IAED,sFAAsF;IACtF,KAAK,CAAC,YAAY;QAChB,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE,CAAC;YACnB,IAAI,CAAC,OAAO,GAAG,KAAK,CAAA;YACpB,OAAM;QACR,CAAC;QACD,IAAI,CAAC;YACH,IAAI,CAAC,OAAO,GAAG,MAAM,IAAI,CAAC,QAAQ,CAAC,WAAW,EAAE,CAAA;QAClD,CAAC;QAAC,MAAM,CAAC;YACP,IAAI,CAAC,OAAO,GAAG,KAAK,CAAA;QACtB,CAAC;IACH,CAAC;IAED,KAAK,CAAC,YAAY,CAAC,MAAc;QAC/B,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE,CAAC;YACnB,eAAU,CAAC,IAAI,CAAC,4EAA4E,CAAC,CAAA;YAC7F,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,CAAA;QACvB,CAAC;QACD,OAAO,IAAI,CAAC,QAAQ,CAAC,YAAY,CAAC,MAAM,CAAC,CAAA;IAC3C,CAAC;IAED,UAAU;QACR,IAAI,CAAC,QAAQ,GAAG,IAAI,CAAA;QACpB,IAAI,CAAC,OAAO,GAAG,KAAK,CAAA;QACpB,eAAU,CAAC,IAAI,CAAC,qCAAqC,CAAC,CAAA;IACxD,CAAC;CACF;AAEY,QAAA,eAAe,GAAG,IAAI,uBAAuB,EAAE,CAAA"} {"version":3,"file":"StorageProvider.js","sourceRoot":"","sources":["StorageProvider.ts"],"names":[],"mappings":"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"}
+75 -14
View File
@@ -11,41 +11,102 @@ class DragonflyClient {
constructor() { constructor() {
this.ttl = parseInt(env_1.env.DRAGONFLY_TTL_SECONDS, 10); this.ttl = parseInt(env_1.env.DRAGONFLY_TTL_SECONDS, 10);
this.client = new ioredis_1.default(env_1.env.DRAGONFLY_URL, { this.client = new ioredis_1.default(env_1.env.DRAGONFLY_URL, {
maxRetriesPerRequest: 3, // null = não limita tentativas por comando; a reconexão é gerenciada pelo retryStrategy
enableReadyCheck: true, maxRetriesPerRequest: null,
enableReadyCheck: false,
lazyConnect: true, lazyConnect: true,
// Falha imediata quando desconectado — evita fila infinita em memória
enableOfflineQueue: false,
// Reconexão exponencial: 200ms → 400ms → ... → 30s
retryStrategy(times) {
const delay = Math.min(200 * 2 ** times, 30000);
return delay;
},
}); });
this.client.on('connect', () => logger_1.logger.info('DragonflyDB conectado')); this.client.on('connect', () => logger_1.logger.info('DragonflyDB conectado'));
this.client.on('error', (err) => logger_1.logger.error(err, 'DragonflyDB erro')); this.client.on('ready', () => logger_1.logger.info('DragonflyDB pronto'));
this.client.on('error', (err) => logger_1.logger.error({ msg: 'DragonflyDB erro', code: err.code }));
this.client.on('close', () => logger_1.logger.warn('DragonflyDB conexão fechada — reconectando...'));
}
/** true se o cliente está conectado e pronto */
isAlive() {
return this.client.status === 'ready';
} }
async connect() { async connect() {
await this.client.connect(); // Inicia conexão em background — não bloqueia o startup se Redis estiver fora
// retryStrategy cuida da reconexão automática
this.client.connect().catch(() => { });
} }
async get(key) { async get(key) {
return this.client.get(key); try {
return await this.client.get(key);
}
catch {
return null;
}
} }
async getJson(key) { async getJson(key) {
const raw = await this.client.get(key); try {
if (!raw) const raw = await this.client.get(key);
if (!raw)
return null;
return JSON.parse(raw);
}
catch {
return null; return null;
return JSON.parse(raw); }
} }
async set(key, value, ttlSeconds) { async set(key, value, ttlSeconds) {
await this.client.set(key, value, 'EX', ttlSeconds ?? this.ttl); try {
const ttl = ttlSeconds ?? this.ttl;
if (ttl > 0) {
await this.client.set(key, value, 'EX', ttl);
}
else {
await this.client.set(key, value);
}
}
catch { /* Redis indisponível — ignora silenciosamente */ }
} }
async setJson(key, value, ttlSeconds) { async setJson(key, value, ttlSeconds) {
await this.client.set(key, JSON.stringify(value), 'EX', ttlSeconds ?? this.ttl); try {
const ttl = ttlSeconds ?? this.ttl;
if (ttl > 0) {
await this.client.set(key, JSON.stringify(value), 'EX', ttl);
}
else {
await this.client.set(key, JSON.stringify(value));
}
}
catch { /* Redis indisponível — ignora silenciosamente */ }
}
async psetex(key, ttlMs, value) {
try {
await this.client.psetex(key, ttlMs, value);
}
catch { }
} }
async del(key) { async del(key) {
await this.client.del(key); try {
await this.client.del(key);
}
catch { }
} }
async exists(key) { async exists(key) {
const count = await this.client.exists(key); try {
return count > 0; const count = await this.client.exists(key);
return count > 0;
}
catch {
return false;
}
} }
/** Publica em um canal pub/sub (usado para broadcasting de QR/status) */ /** Publica em um canal pub/sub (usado para broadcasting de QR/status) */
async publish(channel, message) { async publish(channel, message) {
await this.client.publish(channel, message); try {
await this.client.publish(channel, message);
}
catch { }
} }
raw() { raw() {
return this.client; return this.client;
@@ -1 +1 @@
{"version":3,"file":"dragonfly.js","sourceRoot":"","sources":["dragonfly.ts"],"names":[],"mappings":";;;;;;AAAA,sDAA2B;AAC3B,0CAAsC;AACtC,gDAA4C;AAE5C,MAAM,eAAe;IAInB;QACE,IAAI,CAAC,GAAG,GAAG,QAAQ,CAAC,SAAG,CAAC,qBAAqB,EAAE,EAAE,CAAC,CAAA;QAElD,IAAI,CAAC,MAAM,GAAG,IAAI,iBAAK,CAAC,SAAG,CAAC,aAAa,EAAE;YACzC,oBAAoB,EAAE,CAAC;YACvB,gBAAgB,EAAE,IAAI;YACtB,WAAW,EAAE,IAAI;SAClB,CAAC,CAAA;QAEF,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC,SAAS,EAAE,GAAG,EAAE,CAAC,eAAM,CAAC,IAAI,CAAC,uBAAuB,CAAC,CAAC,CAAA;QACrE,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC,OAAO,EAAE,CAAC,GAAG,EAAE,EAAE,CAAC,eAAM,CAAC,KAAK,CAAC,GAAG,EAAE,kBAAkB,CAAC,CAAC,CAAA;IACzE,CAAC;IAED,KAAK,CAAC,OAAO;QACX,MAAM,IAAI,CAAC,MAAM,CAAC,OAAO,EAAE,CAAA;IAC7B,CAAC;IAED,KAAK,CAAC,GAAG,CAAC,GAAW;QACnB,OAAO,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC,CAAA;IAC7B,CAAC;IAED,KAAK,CAAC,OAAO,CAAI,GAAW;QAC1B,MAAM,GAAG,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC,CAAA;QACtC,IAAI,CAAC,GAAG;YAAE,OAAO,IAAI,CAAA;QACrB,OAAO,IAAI,CAAC,KAAK,CAAC,GAAG,CAAM,CAAA;IAC7B,CAAC;IAED,KAAK,CAAC,GAAG,CAAC,GAAW,EAAE,KAAa,EAAE,UAAmB;QACvD,MAAM,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,EAAE,KAAK,EAAE,IAAI,EAAE,UAAU,IAAI,IAAI,CAAC,GAAG,CAAC,CAAA;IACjE,CAAC;IAED,KAAK,CAAC,OAAO,CAAC,GAAW,EAAE,KAAc,EAAE,UAAmB;QAC5D,MAAM,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,EAAE,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,EAAE,IAAI,EAAE,UAAU,IAAI,IAAI,CAAC,GAAG,CAAC,CAAA;IACjF,CAAC;IAED,KAAK,CAAC,GAAG,CAAC,GAAW;QACnB,MAAM,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC,CAAA;IAC5B,CAAC;IAED,KAAK,CAAC,MAAM,CAAC,GAAW;QACtB,MAAM,KAAK,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,GAAG,CAAC,CAAA;QAC3C,OAAO,KAAK,GAAG,CAAC,CAAA;IAClB,CAAC;IAED,yEAAyE;IACzE,KAAK,CAAC,OAAO,CAAC,OAAe,EAAE,OAAe;QAC5C,MAAM,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,OAAO,EAAE,OAAO,CAAC,CAAA;IAC7C,CAAC;IAED,GAAG;QACD,OAAO,IAAI,CAAC,MAAM,CAAA;IACpB,CAAC;CACF;AAEY,QAAA,SAAS,GAAG,IAAI,eAAe,EAAE,CAAA"} {"version":3,"file":"dragonfly.js","sourceRoot":"","sources":["dragonfly.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,44 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.invalidateChatListCache = invalidateChatListCache;
/**
* chat.cache.ts — Invalidação centralizada do cache da listagem de chats.
*
* O cache é populado em GET /api/chats com TTL de 30s.
* Esta função invalida todas as variações (archived, limit) para um par
* tenant+instance — uma nova mensagem ou alteração no chat força refetch.
*
* Usa SCAN ao invés de KEYS para não bloquear o DragonflyDB em prod.
*/
const dragonfly_1 = require("../../infra/cache/dragonfly");
const logger_1 = require("../../config/logger");
/**
* Remove todas as chaves de cache da listagem de chats para um instance.
* Pattern: chats:list:{tenantId}:{instanceId}:*
* Operação fire-and-forget: erros são logados mas não propagados.
*/
async function invalidateChatListCache(tenantId, instanceId) {
if (!dragonfly_1.dragonfly.isAlive())
return;
try {
const client = dragonfly_1.dragonfly.raw();
const pattern = `chats:list:${tenantId}:${instanceId}:*`;
const stream = client.scanStream({ match: pattern, count: 100 });
const keys = [];
await new Promise((resolve, reject) => {
stream.on('data', (batch) => {
for (const k of batch)
keys.push(k);
});
stream.on('end', () => resolve());
stream.on('error', (err) => reject(err));
});
if (keys.length > 0) {
await client.del(...keys);
}
}
catch (err) {
logger_1.logger.warn({ err, tenantId, instanceId }, '[chat.cache] Falha ao invalidar cache (ignorado)');
}
}
//# sourceMappingURL=chat.cache.js.map
@@ -0,0 +1 @@
{"version":3,"file":"chat.cache.js","sourceRoot":"","sources":["chat.cache.ts"],"names":[],"mappings":";;AAiBA,0DAsBC;AAvCD;;;;;;;;GAQG;AACH,2DAAuD;AACvD,gDAA4C;AAE5C;;;;GAIG;AACI,KAAK,UAAU,uBAAuB,CAAC,QAAgB,EAAE,UAAkB;IAChF,IAAI,CAAC,qBAAS,CAAC,OAAO,EAAE;QAAE,OAAM;IAChC,IAAI,CAAC;QACH,MAAM,MAAM,GAAG,qBAAS,CAAC,GAAG,EAAE,CAAA;QAC9B,MAAM,OAAO,GAAG,cAAc,QAAQ,IAAI,UAAU,IAAI,CAAA;QACxD,MAAM,MAAM,GAAG,MAAM,CAAC,UAAU,CAAC,EAAE,KAAK,EAAE,OAAO,EAAE,KAAK,EAAE,GAAG,EAAE,CAAC,CAAA;QAChE,MAAM,IAAI,GAAa,EAAE,CAAA;QAEzB,MAAM,IAAI,OAAO,CAAO,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;YAC1C,MAAM,CAAC,EAAE,CAAC,MAAM,EAAE,CAAC,KAAe,EAAE,EAAE;gBACpC,KAAK,MAAM,CAAC,IAAI,KAAK;oBAAE,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;YACrC,CAAC,CAAC,CAAA;YACF,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,GAAG,EAAE,CAAC,OAAO,EAAE,CAAC,CAAA;YACjC,MAAM,CAAC,EAAE,CAAC,OAAO,EAAE,CAAC,GAAG,EAAE,EAAE,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAA;QAC1C,CAAC,CAAC,CAAA;QAEF,IAAI,IAAI,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;YACpB,MAAM,MAAM,CAAC,GAAG,CAAC,GAAG,IAAI,CAAC,CAAA;QAC3B,CAAC;IACH,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,eAAM,CAAC,IAAI,CAAC,EAAE,GAAG,EAAE,QAAQ,EAAE,UAAU,EAAE,EAAE,kDAAkD,CAAC,CAAA;IAChG,CAAC;AACH,CAAC"}
@@ -0,0 +1,89 @@
/**
* Rotas de Figurinhas (Stickers)
*
* GET /api/stickers — Lista stickers do tenant (com listVersion)
* GET /api/stickers/recent — Últimos 30 stickers usados
* PATCH /api/stickers/:id/favorite — Toggle isFavorite
*/
import { Router, type Request, type Response } from 'express'
import { createHash } from 'crypto'
import { prisma } from '../../infra/database/prisma'
export function buildStickerRoutes(): Router {
const router = Router()
// ── LIST (com listVersion para cache invalidation) ────────────────────────
router.get('/', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const stickers = await prisma.sticker.findMany({
where: { tenantId },
orderBy: [{ isFavorite: 'desc' }, { lastSeenAt: 'desc' }],
select: {
id: true,
sha256: true,
wasabiPath: true,
isAnimated: true,
isFavorite: true,
useCount: true,
lastSeenAt: true,
},
})
// listVersion muda apenas quando o conjunto de stickers muda
// (novo sha256 chega ou estado de favorito muda)
const versionSource = stickers.length === 0
? 'empty'
: `${stickers.length}:${stickers[0]!.sha256}:${stickers[0]!.lastSeenAt.getTime()}:${stickers.filter(s => s.isFavorite).length}`
const listVersion = createHash('md5').update(versionSource).digest('hex').slice(0, 8)
res.json({ listVersion, stickers })
})
// ── RECENT (últimos 30) ───────────────────────────────────────────────────
router.get('/recent', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const stickers = await prisma.sticker.findMany({
where: { tenantId },
orderBy: { lastSeenAt: 'desc' },
take: 30,
select: {
id: true,
sha256: true,
wasabiPath: true,
isAnimated: true,
isFavorite: true,
},
})
res.json({ stickers })
})
// ── TOGGLE FAVORITE ───────────────────────────────────────────────────────
router.patch('/:id/favorite', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const idParam = req.params['id']
const id = Array.isArray(idParam) ? idParam[0]! : idParam
const sticker = await prisma.sticker.findFirst({
where: { id, tenantId },
select: { id: true, isFavorite: true },
})
if (!sticker) {
res.status(404).json({ error: 'Sticker não encontrado' })
return
}
const updated = await prisma.sticker.update({
where: { id: sticker.id },
data: { isFavorite: !sticker.isFavorite },
select: { id: true, isFavorite: true },
})
res.json(updated)
})
return router
}
@@ -39,7 +39,7 @@ exports.WhatsAppConnectionManager = void 0;
const path_1 = __importDefault(require("path")); const path_1 = __importDefault(require("path"));
const promises_1 = __importDefault(require("fs/promises")); const promises_1 = __importDefault(require("fs/promises"));
const promises_2 = __importDefault(require("dns/promises")); const promises_2 = __importDefault(require("dns/promises"));
const baileys_1 = require("@whiskeysockets/baileys"); const engine_1 = require("../engine");
const qrcode_1 = __importDefault(require("qrcode")); const qrcode_1 = __importDefault(require("qrcode"));
const env_1 = require("../../../config/env"); const env_1 = require("../../../config/env");
const logger_1 = require("../../../config/logger"); const logger_1 = require("../../../config/logger");
@@ -50,6 +50,7 @@ const MessageHandler_1 = require("../handlers/MessageHandler");
const ContactHandler_1 = require("../handlers/ContactHandler"); const ContactHandler_1 = require("../handlers/ContactHandler");
const chatbot_service_1 = require("../../chatbot/chatbot.service"); const chatbot_service_1 = require("../../chatbot/chatbot.service");
const hook_bus_1 = require("../../../core/hook-bus"); const hook_bus_1 = require("../../../core/hook-bus");
const chat_cache_1 = require("../../chats/chat.cache");
// ─── Chaves de cache no DragonflyDB (Redis-compatible) ────────────────────── // ─── Chaves de cache no DragonflyDB (Redis-compatible) ──────────────────────
const CACHE_KEY_STATUS = (instanceId) => `instance:${instanceId}:status`; const CACHE_KEY_STATUS = (instanceId) => `instance:${instanceId}:status`;
const CACHE_KEY_QR = (instanceId) => `instance:${instanceId}:qr`; const CACHE_KEY_QR = (instanceId) => `instance:${instanceId}:qr`;
@@ -80,7 +81,8 @@ const PROBE_DNS_HOST = 'web.whatsapp.com';
// - Cool-down entre kills: evita loop de reconexão se a rede oscila // - Cool-down entre kills: evita loop de reconexão se a rede oscila
const WATCHDOG_INTERVAL_MS = 180000; // verifica a cada 3 min const WATCHDOG_INTERVAL_MS = 180000; // verifica a cada 3 min
const WATCHDOG_TIMEOUT_MS = 600000; // 10 min sem atividade antes de agir const WATCHDOG_TIMEOUT_MS = 600000; // 10 min sem atividade antes de agir
const WATCHDOG_PING_GRACE_MS = 30000; // aguarda 30s após ping antes de matar const WATCHDOG_PING_GRACE_MS = 30000; // grace padrão (official): 30s
const WATCHDOG_PING_GRACE_INFIN_MS = 60000; // grace InfiniteAPI: 60s (ACK mais lento)
const WATCHDOG_COOLDOWN_MS = 900000; // 15 min entre force-kills consecutivos const WATCHDOG_COOLDOWN_MS = 900000; // 15 min entre force-kills consecutivos
class WhatsAppConnectionManager { class WhatsAppConnectionManager {
constructor(io) { constructor(io) {
@@ -113,6 +115,8 @@ class WhatsAppConnectionManager {
this.lastWatchdogPing = new Map(); this.lastWatchdogPing = new Map();
/** Mapeamento instanceId → tenantId (necessário para reconexões) */ /** Mapeamento instanceId → tenantId (necessário para reconexões) */
this.tenantByInstance = new Map(); this.tenantByInstance = new Map();
/** Engine ativa por instância (usada pelo watchdog para ajustar tolerâncias) */
this.engineByInstance = new Map();
this.io = io; this.io = io;
this.chatbotService = new chatbot_service_1.ChatbotService(io); this.chatbotService = new chatbot_service_1.ChatbotService(io);
this.startNetworkProbe(); this.startNetworkProbe();
@@ -163,12 +167,23 @@ class WhatsAppConnectionManager {
this.io.to(`tenant:${tenantId}`).emit('instance:status', { instanceId, status: client_1.InstanceStatus.DISCONNECTED }); this.io.to(`tenant:${tenantId}`).emit('instance:status', { instanceId, status: client_1.InstanceStatus.DISCONNECTED });
return; return;
} }
const { state, saveCreds } = await (0, baileys_1.useMultiFileAuthState)(sessionDir); // ── Seleciona engine por instância ───────────────────────────────────
// Lê o campo engine da instância no banco (padrão: "infinite").
// Permite que cada instância use uma engine diferente sem reiniciar o container.
const instanceRecord = await prisma_1.prisma.instance.findFirst({
where: { id: instanceId },
select: { engine: true },
});
const engineType = instanceRecord?.engine ?? 'infinite';
const { makeWASocket, useMultiFileAuthState, fetchLatestBaileysVersion, makeCacheableSignalKeyStore, } = (0, engine_1.getEngine)(engineType);
this.engineByInstance.set(instanceId, engineType);
logger_1.logger.info({ instanceId, engine: engineType }, 'Engine selecionada para instância');
const { state, saveCreds } = await useMultiFileAuthState(sessionDir);
// fetchLatestBaileysVersion faz requisição HTTP ao WhatsApp — pode falhar // fetchLatestBaileysVersion faz requisição HTTP ao WhatsApp — pode falhar
// se não houver internet. Usa versão embutida como fallback seguro. // se não houver internet. Usa versão embutida como fallback seguro.
let version = [2, 3000, 1023504162]; let version = [2, 3000, 1023504162];
try { try {
const fetched = await (0, baileys_1.fetchLatestBaileysVersion)(); const fetched = await fetchLatestBaileysVersion();
version = fetched.version; version = fetched.version;
} }
catch (err) { catch (err) {
@@ -187,12 +202,12 @@ class WhatsAppConnectionManager {
}, },
debug: () => { }, info: () => { }, warn: () => { }, error: () => { }, fatal: () => { }, debug: () => { }, info: () => { }, warn: () => { }, error: () => { }, fatal: () => { },
}; };
const sock = (0, baileys_1.makeWASocket)({ const sock = makeWASocket({
version, version,
auth: { auth: {
creds: state.creds, creds: state.creds,
// Cache de chaves Signal para performance (evita leituras de disco) // Cache de chaves Signal para performance (evita leituras de disco)
keys: (0, baileys_1.makeCacheableSignalKeyStore)(state.keys, logger_1.logger), keys: makeCacheableSignalKeyStore(state.keys, logger_1.logger),
}, },
printQRInTerminal: false, printQRInTerminal: false,
qrTimeout: 60000, qrTimeout: 60000,
@@ -215,15 +230,7 @@ class WhatsAppConnectionManager {
}); });
this.sockets.set(instanceId, sock); this.sockets.set(instanceId, sock);
this.touchActivity(instanceId); this.touchActivity(instanceId);
// ═════════════════════════════════════════════════════════════════════
// REGISTRO DE EVENT LISTENERS DO BAILEYS
// ═════════════════════════════════════════════════════════════════════
// ─── Credenciais: salvar ao atualizar ────────────────────────────────
// O Baileys atualiza credenciais periodicamente (rotação de chaves Signal)
sock.ev.on('creds.update', saveCreds); sock.ev.on('creds.update', saveCreds);
// ─── Estado de conexão ───────────────────────────────────────────────
// Eventos: qr (novo QR gerado), open (conectado), close (desconectado),
// connecting (tentando conectar)
sock.ev.on('connection.update', (update) => { sock.ev.on('connection.update', (update) => {
this.touchActivity(instanceId); this.touchActivity(instanceId);
this.baileysLog(tenantId, instanceId, 'connection.update', { this.baileysLog(tenantId, instanceId, 'connection.update', {
@@ -281,9 +288,6 @@ class WhatsAppConnectionManager {
// Delega processamento completo para o MessageHandler // Delega processamento completo para o MessageHandler
msgHandler.handle(m); msgHandler.handle(m);
}); });
// ─── Status de mensagens (sent → delivered → read) ───────────────────
// O WhatsApp envia atualizações de status conforme a mensagem é entregue
// e lida. Mapeamos os códigos numéricos para strings legíveis.
sock.ev.on('messages.update', (updates) => { sock.ev.on('messages.update', (updates) => {
this.touchActivity(instanceId); this.touchActivity(instanceId);
for (const { key, update } of updates) { for (const { key, update } of updates) {
@@ -332,16 +336,6 @@ class WhatsAppConnectionManager {
this.baileysLog(tenantId, instanceId, 'contacts.update', { count: contacts.length }); this.baileysLog(tenantId, instanceId, 'contacts.update', { count: contacts.length });
contactHandler.update(contacts); contactHandler.update(contacts);
}); });
// ─── LID → Phone mapping (chats.phoneNumberShare) ────────────────────
// Evento emitido quando um contato envia uma mensagem de protocolo
// SHARE_PHONE_NUMBER, revelando o telefone real por trás do LID.
//
// Este é um dos mecanismos para construir o mapeamento LID → phone.
// Outros mecanismos:
// - key.senderPn nas mensagens (MessageHandler.resolveLidToPhoneJid)
// - campo `lid` nos contatos do history sync (ContactHandler.upsert)
//
// @see MessageHandler.resolveLidToPhoneJid() — consome este mapeamento
sock.ev.on('chats.phoneNumberShare', ({ lid, jid: phoneJid }) => { sock.ev.on('chats.phoneNumberShare', ({ lid, jid: phoneJid }) => {
this.touchActivity(instanceId); this.touchActivity(instanceId);
logger_1.logger.info({ instanceId, lid, phoneJid }, '[LID] phoneNumberShare recebido'); logger_1.logger.info({ instanceId, lid, phoneJid }, '[LID] phoneNumberShare recebido');
@@ -359,9 +353,6 @@ class WhatsAppConnectionManager {
}).catch(() => { }); }).catch(() => { });
} }
}); });
// ─── Chats ───────────────────────────────────────────────────────────
// chats.upsert: novos chats (ou chats que voltaram da lista)
// chats.update: atualizações (unread count, pin, archive, etc)
sock.ev.on('chats.upsert', (chats) => { sock.ev.on('chats.upsert', (chats) => {
this.touchActivity(instanceId); this.touchActivity(instanceId);
this.baileysLog(tenantId, instanceId, 'chats.upsert', { count: chats.length }); this.baileysLog(tenantId, instanceId, 'chats.upsert', { count: chats.length });
@@ -372,10 +363,6 @@ class WhatsAppConnectionManager {
this.baileysLog(tenantId, instanceId, 'chats.update', { count: chats.length }); this.baileysLog(tenantId, instanceId, 'chats.update', { count: chats.length });
this.handleChatsUpdate(instanceId, tenantId, chats).catch(() => { }); this.handleChatsUpdate(instanceId, tenantId, chats).catch(() => { });
}); });
// ─── Grupos ──────────────────────────────────────────────────────────
// groups.upsert: grupo criado ou entrou em novo grupo
// groups.update: subject (nome) ou metadata alterados
// Convertemos para o formato de chat e sincronizamos via syncChats()
sock.ev.on('groups.upsert', (groups) => { sock.ev.on('groups.upsert', (groups) => {
this.touchActivity(instanceId); this.touchActivity(instanceId);
this.baileysLog(tenantId, instanceId, 'groups.upsert', { count: groups.length }); this.baileysLog(tenantId, instanceId, 'groups.upsert', { count: groups.length });
@@ -397,10 +384,6 @@ class WhatsAppConnectionManager {
.map((g) => ({ id: g.id, name: g.subject })); .map((g) => ({ id: g.id, name: g.subject }));
this.syncChats(instanceId, tenantId, chatsFromUpdates).catch(() => { }); this.syncChats(instanceId, tenantId, chatsFromUpdates).catch(() => { });
}); });
// ─── Histórico inicial (messaging-history.set) ───────────────────────
// Evento principal de sincronização: o telefone envia chats, contatos
// e mensagens em lotes. Pode disparar múltiplas vezes.
// Quando isLatest=true, o sync terminou → dispara reconciliação de nomes.
sock.ev.on('messaging-history.set', async ({ chats: histChats, contacts: histContacts, messages: histMessages, isLatest }) => { sock.ev.on('messaging-history.set', async ({ chats: histChats, contacts: histContacts, messages: histMessages, isLatest }) => {
this.touchActivity(instanceId); this.touchActivity(instanceId);
logger_1.logger.info({ logger_1.logger.info({
@@ -534,10 +517,11 @@ class WhatsAppConnectionManager {
this.sockets.delete(instanceId); this.sockets.delete(instanceId);
this.contactHandlers.delete(instanceId); this.contactHandlers.delete(instanceId);
this.msgHandlers.delete(instanceId); this.msgHandlers.delete(instanceId);
this.engineByInstance.delete(instanceId);
this.stopWatchdog(instanceId); this.stopWatchdog(instanceId);
// Decide se deve reconectar baseado no código de erro // Decide se deve reconectar baseado no código de erro
const shouldReconnect = statusCode !== baileys_1.DisconnectReason.loggedOut && const shouldReconnect = statusCode !== engine_1.DisconnectReason.loggedOut &&
statusCode !== baileys_1.DisconnectReason.forbidden; statusCode !== engine_1.DisconnectReason.forbidden;
if (shouldReconnect) { if (shouldReconnect) {
const attempts = (this.reconnectAttempts.get(instanceId) ?? 0) + 1; const attempts = (this.reconnectAttempts.get(instanceId) ?? 0) + 1;
this.reconnectAttempts.set(instanceId, attempts); this.reconnectAttempts.set(instanceId, attempts);
@@ -562,7 +546,7 @@ class WhatsAppConnectionManager {
} }
else { else {
// loggedOut → limpa sessão e exige novo QR // loggedOut → limpa sessão e exige novo QR
if (statusCode === baileys_1.DisconnectReason.loggedOut) { if (statusCode === engine_1.DisconnectReason.loggedOut) {
logger_1.logger.warn({ instanceId }, 'Sessao desconectada pelo usuario — limpando sessao'); logger_1.logger.warn({ instanceId }, 'Sessao desconectada pelo usuario — limpando sessao');
await this.deleteSessionFiles(instanceId); await this.deleteSessionFiles(instanceId);
} }
@@ -571,7 +555,7 @@ class WhatsAppConnectionManager {
this.io.to(`tenant:${tenantId}`).emit('instance:status', { this.io.to(`tenant:${tenantId}`).emit('instance:status', {
instanceId, instanceId,
status: client_1.InstanceStatus.DISCONNECTED, status: client_1.InstanceStatus.DISCONNECTED,
reason: baileys_1.DisconnectReason[statusCode] ?? statusCode, reason: engine_1.DisconnectReason[statusCode] ?? statusCode,
}); });
hook_bus_1.hookBus.emit('ext:session.status', { instanceId, tenantId, status: 'disconnected', timestamp: Date.now() }).catch(() => { }); hook_bus_1.hookBus.emit('ext:session.status', { instanceId, tenantId, status: 'disconnected', timestamp: Date.now() }).catch(() => { });
} }
@@ -693,8 +677,10 @@ class WhatsAppConnectionManager {
return; return;
} }
// Etapa 2: ping já foi enviado, mas ainda sem atividade após GRACE. // Etapa 2: ping já foi enviado, mas ainda sem atividade após GRACE.
// Confirma que o socket está zumbi e força reconexão. // InfiniteAPI pode ter ACK mais lento — usa tolerância maior.
if (sincePing > WATCHDOG_PING_GRACE_MS) { const engine = this.engineByInstance.get(instanceId) ?? 'infinite';
const graceMs = engine === 'infinite' ? WATCHDOG_PING_GRACE_INFIN_MS : WATCHDOG_PING_GRACE_MS;
if (sincePing > graceMs) {
this.forceKillSocket(instanceId, sock, tenantId, elapsed); this.forceKillSocket(instanceId, sock, tenantId, elapsed);
} }
}, WATCHDOG_INTERVAL_MS); }, WATCHDOG_INTERVAL_MS);
@@ -868,6 +854,7 @@ class WhatsAppConnectionManager {
this.sockets.delete(instanceId); this.sockets.delete(instanceId);
this.contactHandlers.delete(instanceId); this.contactHandlers.delete(instanceId);
this.msgHandlers.delete(instanceId); this.msgHandlers.delete(instanceId);
this.engineByInstance.delete(instanceId);
logger_1.logger.info({ instanceId }, 'Instancia desconectada manualmente'); logger_1.logger.info({ instanceId }, 'Instancia desconectada manualmente');
} }
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
@@ -962,17 +949,27 @@ class WhatsAppConnectionManager {
// ── Resolução LID → phone JID ───────────────────────────────────── // ── Resolução LID → phone JID ─────────────────────────────────────
// Chats @lid são artefatos do protocolo WhatsApp multi-device: // Chats @lid são artefatos do protocolo WhatsApp multi-device:
// o JID real do contato é @s.whatsapp.net. // o JID real do contato é @s.whatsapp.net.
// Se já temos o mapeamento (ContactHandler rodou antes), usa o phone JID. // Fontes de resolução (ordem de prioridade):
// Se não temos resolução, descarta — seria filtrado pelo ChatRepository. // 1. Mapeamento no banco (ContactHandler.upsert populou via lidJid)
// 2. Campo pnJid do objeto de chat — enviado pelo InfiniteAPI v7+ no
// messaging-history.set direto do proto.IConversation.pnJid
let jid = rawJid; let jid = rawJid;
if (rawJid.endsWith('@lid')) { if (rawJid.endsWith('@lid')) {
const phoneJid = lidToPhoneJid.get(rawJid); const phoneJid = lidToPhoneJid.get(rawJid)
?? chat.pnJid;
if (phoneJid) { if (phoneJid) {
jid = phoneJid; jid = phoneJid;
// Garante que o contactId mapeado seja usado abaixo // Garante que o contactId mapeado seja usado abaixo
const resolvedContactId = lidToContactId.get(rawJid) ?? contactIdByJid.get(jid); const resolvedContactId = lidToContactId.get(rawJid) ?? contactIdByJid.get(jid);
if (resolvedContactId) if (resolvedContactId)
contactIdByJid.set(jid, resolvedContactId); contactIdByJid.set(jid, resolvedContactId);
// Persiste o mapeamento no banco caso tenha vindo só do pnJid (fallback)
if (!lidToPhoneJid.has(rawJid)) {
prisma_1.prisma.contact.updateMany({
where: { tenantId, instanceId, jid: phoneJid },
data: { lidJid: rawJid },
}).catch(() => { });
}
} }
else { else {
// Sem resolução disponível: chat será criado via handleHistory quando // Sem resolução disponível: chat será criado via handleHistory quando
@@ -982,10 +979,12 @@ class WhatsAppConnectionManager {
} }
} }
try { try {
// Converte timestamp Unix (segundos) para Date // Converte timestamp Unix (segundos) para Date.
const lastAt = chat.conversationTimestamp // Fallback: lastMessageRecvTimestamp (InfiniteAPI define a partir das mensagens do sync)
? new Date(Number(chat.conversationTimestamp) * 1000) // quando conversationTimestamp é 0 ou null (ex: chats antigos fora da janela de sync).
: undefined; const rawTs = Number(chat.conversationTimestamp || 0)
|| Number(chat.lastMessageRecvTimestamp || 0);
const lastAt = rawTs > 0 ? new Date(rawTs * 1000) : undefined;
const isGroup = jid.endsWith('@g.us'); const isGroup = jid.endsWith('@g.us');
// Nome do chat: apenas grupos têm nome (subject) — individuais usam Contact.name // Nome do chat: apenas grupos têm nome (subject) — individuais usam Contact.name
const groupName = isGroup ? (chat.name ?? null) : null; const groupName = isGroup ? (chat.name ?? null) : null;
@@ -1021,6 +1020,8 @@ class WhatsAppConnectionManager {
logger_1.logger.error({ err, jid }, '[WhatsApp] Erro ao sincronizar chat'); logger_1.logger.error({ err, jid }, '[WhatsApp] Erro ao sincronizar chat');
} }
} }
// Invalida cache da listagem de chats — refletir alterações imediatamente na inbox
(0, chat_cache_1.invalidateChatListCache)(tenantId, instanceId).catch(() => { });
} }
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
// HANDLER DE ATUALIZAÇÕES PARCIAIS DE CHAT (chats.update) // HANDLER DE ATUALIZAÇÕES PARCIAIS DE CHAT (chats.update)
@@ -1102,6 +1103,7 @@ class WhatsAppConnectionManager {
logger_1.logger.error({ err, jid }, '[WhatsApp] Erro ao aplicar chats.update'); logger_1.logger.error({ err, jid }, '[WhatsApp] Erro ao aplicar chats.update');
} }
} }
(0, chat_cache_1.invalidateChatListCache)(tenantId, instanceId).catch(() => { });
} }
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
// DEBUG LOG (página /baileys no frontend) // DEBUG LOG (página /baileys no frontend)
File diff suppressed because one or more lines are too long
@@ -0,0 +1,80 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.proto = exports.isJidStatusBroadcast = exports.isJidGroup = exports.generateWAMessageFromContent = exports.generateMessageIDV2 = exports.getContentType = exports.downloadMediaMessage = exports.makeCacheableSignalKeyStore = exports.fetchLatestBaileysVersion = exports.DisconnectReason = exports.useMultiFileAuthState = exports.makeWASocket = void 0;
exports.getEngine = getEngine;
const official = __importStar(require("./official"));
const infinite = __importStar(require("./infinite"));
// Engine padrão global: lida de BAILEYS_ENGINE (fallback para 'infinite')
const defaultEngineType = process.env.BAILEYS_ENGINE ?? 'infinite';
function resolveEngine(type) {
return type === 'infinite' ? infinite : official;
}
/**
* Retorna os exports da engine selecionada para uma instância específica.
* Permite que cada instância use uma engine diferente (infinite ou official).
* Se não informado, usa BAILEYS_ENGINE do ambiente (padrão: 'infinite').
*/
function getEngine(engineType) {
const e = resolveEngine(engineType ?? defaultEngineType);
return {
makeWASocket: e.makeWASocket,
useMultiFileAuthState: e.useMultiFileAuthState,
fetchLatestBaileysVersion: e.fetchLatestBaileysVersion,
makeCacheableSignalKeyStore: e.makeCacheableSignalKeyStore,
downloadMediaMessage: e.downloadMediaMessage,
getContentType: e.getContentType,
generateMessageIDV2: e.generateMessageIDV2,
generateWAMessageFromContent: e.generateWAMessageFromContent,
isJidGroup: e.isJidGroup,
isJidStatusBroadcast: e.isJidStatusBroadcast,
};
}
// Exports estáticos: usam a engine global (retrocompatibilidade com código existente)
const selectedEngine = resolveEngine(defaultEngineType);
exports.makeWASocket = selectedEngine.makeWASocket;
exports.useMultiFileAuthState = selectedEngine.useMultiFileAuthState;
exports.DisconnectReason = selectedEngine.DisconnectReason;
exports.fetchLatestBaileysVersion = selectedEngine.fetchLatestBaileysVersion;
exports.makeCacheableSignalKeyStore = selectedEngine.makeCacheableSignalKeyStore;
exports.downloadMediaMessage = selectedEngine.downloadMediaMessage;
exports.getContentType = selectedEngine.getContentType;
exports.generateMessageIDV2 = selectedEngine.generateMessageIDV2;
exports.generateWAMessageFromContent = selectedEngine.generateWAMessageFromContent;
exports.isJidGroup = selectedEngine.isJidGroup;
exports.isJidStatusBroadcast = selectedEngine.isJidStatusBroadcast;
var official_1 = require("./official");
Object.defineProperty(exports, "proto", { enumerable: true, get: function () { return official_1.proto; } });
//# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
{"version":3,"file":"index.js","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAeA,8BAyBC;AAxCD,qDAAsC;AACtC,qDAAsC;AAEtC,0EAA0E;AAC1E,MAAM,iBAAiB,GAAG,OAAO,CAAC,GAAG,CAAC,cAAc,IAAI,UAAU,CAAA;AAElE,SAAS,aAAa,CAAC,IAAY;IACjC,OAAO,IAAI,KAAK,UAAU,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,QAAQ,CAAA;AAClD,CAAC;AAED;;;;GAIG;AACH,SAAgB,SAAS,CAAC,UAA0B;IAYlD,MAAM,CAAC,GAAG,aAAa,CAAC,UAAU,IAAI,iBAAiB,CAAC,CAAA;IACxD,OAAO;QACL,YAAY,EAAgB,CAAC,CAAC,YAAmB;QACjD,qBAAqB,EAAO,CAAC,CAAC,qBAA4B;QAC1D,yBAAyB,EAAG,CAAC,CAAC,yBAAgC;QAC9D,2BAA2B,EAAE,CAAC,CAAC,2BAAkC;QACjE,oBAAoB,EAAQ,CAAC,CAAC,oBAA2B;QACzD,cAAc,EAAc,CAAC,CAAC,cAAqB;QACnD,mBAAmB,EAAS,CAAC,CAAC,mBAA0B;QACxD,4BAA4B,EAAE,CAAC,CAAC,4BAAmC;QACnE,UAAU,EAAkB,CAAC,CAAC,UAAiB;QAC/C,oBAAoB,EAAQ,CAAC,CAAC,oBAA2B;KAC1D,CAAA;AACH,CAAC;AAED,sFAAsF;AACtF,MAAM,cAAc,GAAG,aAAa,CAAC,iBAAiB,CAAC,CAAA;AAE1C,QAAA,YAAY,GAAG,cAAc,CAAC,YAAmB,CAAA;AACjD,QAAA,qBAAqB,GAAG,cAAc,CAAC,qBAA4B,CAAA;AACnE,QAAA,gBAAgB,GAAG,cAAc,CAAC,gBAAuB,CAAA;AACzD,QAAA,yBAAyB,GAAG,cAAc,CAAC,yBAAgC,CAAA;AAC3E,QAAA,2BAA2B,GAAG,cAAc,CAAC,2BAAkC,CAAA;AAC/E,QAAA,oBAAoB,GAAG,cAAc,CAAC,oBAA2B,CAAA;AACjE,QAAA,cAAc,GAAG,cAAc,CAAC,cAAqB,CAAA;AACrD,QAAA,mBAAmB,GAAG,cAAc,CAAC,mBAA0B,CAAA;AAC/D,QAAA,4BAA4B,GAAG,cAAc,CAAC,4BAAmC,CAAA;AACjF,QAAA,UAAU,GAAG,cAAc,CAAC,UAAiB,CAAA;AAC7C,QAAA,oBAAoB,GAAG,cAAc,CAAC,oBAA2B,CAAA;AAC9E,uCAAkC;AAAzB,iGAAA,KAAK,OAAA"}
@@ -0,0 +1,18 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __exportStar = (this && this.__exportStar) || function(m, exports) {
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
};
Object.defineProperty(exports, "__esModule", { value: true });
__exportStar(require("baileys"), exports);
//# sourceMappingURL=infinite.js.map
@@ -0,0 +1 @@
{"version":3,"file":"infinite.js","sourceRoot":"","sources":["infinite.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;AAAA,0CAAuB"}
@@ -0,0 +1,18 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __exportStar = (this && this.__exportStar) || function(m, exports) {
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
};
Object.defineProperty(exports, "__esModule", { value: true });
__exportStar(require("@whiskeysockets/baileys"), exports);
//# sourceMappingURL=official.js.map
@@ -0,0 +1 @@
{"version":3,"file":"official.js","sourceRoot":"","sources":["official.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;AAAA,0DAAuC"}
@@ -88,10 +88,19 @@ class ContactHandler {
// Ex: { id: "5511999999999@s.whatsapp.net", lid: "123456789@lid" } // Ex: { id: "5511999999999@s.whatsapp.net", lid: "123456789@lid" }
const lidFromContact = c.lid; const lidFromContact = c.lid;
try { try {
// Para contatos @lid que possuem campo `jid` (vem do history sync), // Para contatos @lid que possuem o JID real do telefone:
// usa o JID real como chave primária. Caso contrário, usa c.id como está. // - Baileys oficial envia o campo `jid` (@s.whatsapp.net)
// - InfiniteAPI v7+ envia o campo `phoneNumber` (@s.whatsapp.net)
// Ambos são suportados — o primeiro disponível vence.
// Isso evita criar contatos duplicados (um com LID, outro com phone). // Isso evita criar contatos duplicados (um com LID, outro com phone).
const contactJid = isLid && c.jid ? c.jid : c.id; const pnJid = c.jid
?? c.phoneNumber;
const contactJid = isLid && pnJid ? pnJid : c.id;
// Quando o contato É um @lid mas foi resolvido para phoneJid:
// lidJid = c.id (o @lid original) para manter o mapeamento no banco
// Quando o contato TEM campo `lid` (history sync Baileys oficial):
// lidJid = lidFromContact
const finalLidJid = isLid && pnJid ? c.id : (lidFromContact ?? undefined);
const contact = await prisma_1.prisma.contact.upsert({ const contact = await prisma_1.prisma.contact.upsert({
where: { where: {
tenantId_instanceId_jid: { tenantId_instanceId_jid: {
@@ -109,45 +118,46 @@ class ContactHandler {
notify: c.notify ?? null, notify: c.notify ?? null,
phone: contactJid.endsWith('@lid') ? null : contactJid.split('@')[0], phone: contactJid.endsWith('@lid') ? null : contactJid.split('@')[0],
...(imgUrl && imgUrl !== 'changed' ? { avatarUrl: imgUrl } : {}), ...(imgUrl && imgUrl !== 'changed' ? { avatarUrl: imgUrl } : {}),
// Salva mapeamento LID: ...(finalLidJid ? { lidJid: finalLidJid } : {}),
// Se o contato É um LID (c.id termina em @lid): salva c.id como lidJid
// Se o contato TEM campo lid (history sync): salva lid como lidJid
...(isLid ? { lidJid: c.id } : lidFromContact ? { lidJid: lidFromContact } : {}),
}, },
update: { update: {
name: this.resolveName(c) ?? null, name: this.resolveName(c) ?? null,
verifiedName: c.verifiedName ?? null, verifiedName: c.verifiedName ?? null,
notify: c.notify ?? null, notify: c.notify ?? null,
...(imgUrl && imgUrl !== 'changed' ? { avatarUrl: imgUrl } : {}), ...(imgUrl && imgUrl !== 'changed' ? { avatarUrl: imgUrl } : {}),
...(isLid ? { lidJid: c.id } : lidFromContact ? { lidJid: lidFromContact } : {}), ...(finalLidJid ? { lidJid: finalLidJid } : {}),
}, },
}); });
// Garante vínculo Chat ↔ Contact para chats órfãos // Garante vínculo Chat ↔ Contact para chats órfãos
// (chats criados antes do contato existir ficam com contactId=null) // (chats criados antes do contato existir ficam com contactId=null)
// Usa contactJid (JID resolvido) porque chats @lid são salvos com phone JID.
// Quando c.id é @lid mas foi resolvido para pnJid, o chat no banco tem pnJid.
await prisma_1.prisma.chat.updateMany({ await prisma_1.prisma.chat.updateMany({
where: { where: {
tenantId: this.tenantId, tenantId: this.tenantId,
instanceId: this.instanceId, instanceId: this.instanceId,
jid: c.id, jid: contactJid,
contactId: null, contactId: null,
}, },
data: { contactId: contact.id }, data: { contactId: contact.id },
}); });
// ── Lógica de avatar ────────────────────────────────────────────── // ── Lógica de avatar ──────────────────────────────────────────────
// JIDs @lid não suportam profilePictureUrl (retorna 404 no WA) // JIDs @lid não suportam profilePictureUrl. Usa contactJid para verificar
if (!isLid) { // pois @lid resolvido para phone JID deve buscar avatar normalmente.
const resolvedIsLid = contactJid.endsWith('@lid');
if (!resolvedIsLid) {
if (imgUrl && imgUrl !== 'changed') { if (imgUrl && imgUrl !== 'changed') {
// Avatar veio pronto do Baileys — notifica frontend imediatamente // Avatar veio pronto do Baileys — notifica frontend imediatamente
directAvatarCount++; directAvatarCount++;
this.io?.to(`tenant:${this.tenantId}`).emit('contact:avatar', { this.io?.to(`tenant:${this.tenantId}`).emit('contact:avatar', {
instanceId: this.instanceId, instanceId: this.instanceId,
jid: c.id, jid: contactJid,
avatarUrl: imgUrl, avatarUrl: imgUrl,
}); });
} }
else if (imgUrl === 'changed' || imgUrl === undefined) { else if (imgUrl === 'changed' || imgUrl === undefined) {
// Precisa buscar via API — adiciona à fila com rate-limit // Precisa buscar via API — adiciona à fila com rate-limit
needFetchAvatar.push(c.id); needFetchAvatar.push(contactJid);
} }
} }
} }
File diff suppressed because one or more lines are too long
@@ -28,13 +28,14 @@ exports.MessageHandler = void 0;
*/ */
const path_1 = __importDefault(require("path")); const path_1 = __importDefault(require("path"));
const promises_1 = __importDefault(require("fs/promises")); const promises_1 = __importDefault(require("fs/promises"));
const baileys_1 = require("@whiskeysockets/baileys"); const engine_1 = require("../engine");
const client_1 = require("@prisma/client"); const client_1 = require("@prisma/client");
const prisma_1 = require("../../../infra/database/prisma"); const prisma_1 = require("../../../infra/database/prisma");
const logger_1 = require("../../../config/logger"); const logger_1 = require("../../../config/logger");
const whatsapp_1 = require("../../../shared/utils/whatsapp"); const whatsapp_1 = require("../../../shared/utils/whatsapp");
const StorageProvider_1 = require("../../../core/StorageProvider"); const StorageProvider_1 = require("../../../core/StorageProvider");
const hook_bus_1 = require("../../../core/hook-bus"); const hook_bus_1 = require("../../../core/hook-bus");
const chat_cache_1 = require("../../chats/chat.cache");
/** Diretório raiz onde os arquivos de mídia são salvos (organizados por instanceId) */ /** Diretório raiz onde os arquivos de mídia são salvos (organizados por instanceId) */
const MEDIA_DIR = path_1.default.resolve('./media'); const MEDIA_DIR = path_1.default.resolve('./media');
/** /**
@@ -168,6 +169,72 @@ class MessageHandler {
* (será capturada quando o mapeamento LID for descoberto via phoneNumberShare). * (será capturada quando o mapeamento LID for descoberto via phoneNumberShare).
*/ */
async handleHistory(messages) { async handleHistory(messages) {
if (messages.length === 0)
return;
// Define o limite de 2 dias atrás em segundos (timestamp do WhatsApp usa segundos)
const doisDiasAtrasEmSegundos = Math.floor((Date.now() - 2 * 24 * 60 * 60 * 1000) / 1000);
const mensagensRecentes = [];
const mensagensAntigas = [];
// Separa as mensagens por idade
for (const msg of messages) {
const ts = Number(msg.messageTimestamp ?? 0);
if (ts >= doisDiasAtrasEmSegundos) {
mensagensRecentes.push(msg);
}
else {
mensagensAntigas.push(msg);
}
}
// 1. PROCESSAMENTO DE ALTA PRIORIDADE (Imediato)
if (mensagensRecentes.length > 0) {
logger_1.logger.info({
instanceId: this.instanceId,
count: mensagensRecentes.length
}, '[History] Sincronizando conversas recentes (últimos 2 dias) com prioridade máxima!');
await this.processHistoryBatch(mensagensRecentes);
}
// 2. PROCESSAMENTO DE BAIXA PRIORIDADE (Gradual em segundo plano)
if (mensagensAntigas.length > 0) {
logger_1.logger.info({
instanceId: this.instanceId,
count: mensagensAntigas.length
}, '[History] Agendando histórico antigo para sincronização gradual em background...');
this.processOlderHistoryGradually(mensagensAntigas);
}
}
/**
* Executa o processamento gradual do histórico antigo em lotes de 100 msgs a cada 3 segundos
*/
processOlderHistoryGradually(messages) {
const tamanhoDoLote = 100;
const intervaloMs = 3000; // 3 segundos de folga para a CPU/Postgres respirar
let offset = 0;
const processarProximoLote = async () => {
// Se processou tudo, encerra o ciclo
if (offset >= messages.length) {
logger_1.logger.info({ instanceId: this.instanceId }, '[History] Sincronização completa de histórico antigo concluída com sucesso!');
return;
}
// Corta o próximo pedaço do histórico
const lote = messages.slice(offset, offset + tamanhoDoLote);
offset += tamanhoDoLote;
try {
await this.processHistoryBatch(lote);
}
catch (err) {
logger_1.logger.error({ err, instanceId: this.instanceId }, '[History] Erro ao processar lote gradual de histórico antigo');
}
// Agenda o próximo lote de forma não bloqueante
setTimeout(processarProximoLote, intervaloMs).unref();
};
// Inicia o sincronismo em background após 5 segundos, garantindo que
// a conexão inicial e o frontend já estejam 100% carregados e estáveis
setTimeout(processarProximoLote, 5000).unref();
}
/**
* Lógica original de agrupamento e escrita no banco (Refatorada em método auxiliar)
*/
async processHistoryBatch(messages) {
if (messages.length === 0) if (messages.length === 0)
return; return;
// JID próprio da sessão (ex: "5511999@s.whatsapp.net") — usado para filtrar self-chat // JID próprio da sessão (ex: "5511999@s.whatsapp.net") — usado para filtrar self-chat
@@ -186,28 +253,39 @@ class MessageHandler {
// O history sync do WhatsApp pode enviar mensagens com remoteJid @lid. // O history sync do WhatsApp pode enviar mensagens com remoteJid @lid.
// Precisamos converter para @s.whatsapp.net antes de persistir, // Precisamos converter para @s.whatsapp.net antes de persistir,
// caso contrário o chat ficaria duplicado (um com LID, outro com phone). // caso contrário o chat ficaria duplicado (um com LID, outro com phone).
//
// Fontes de resolução (ordem de prioridade):
// 1. key.remoteJidAlt — InfiniteAPI v7+ inclui o JID alternativo (phone ↔ LID)
// 2. key.senderPn / participantPn — campo do protocolo WA
// 3. DB lookup — contacts.lidJid salvo por sessões anteriores
if (jidRaw.endsWith('@lid')) { if (jidRaw.endsWith('@lid')) {
// Tenta key.senderPn (disponível quando o protocolo WA inclui sender_pn) // 1. remoteJidAlt: InfiniteAPI preenche o JID alternativo em mensagens históricas.
const senderPn = msg.key.senderPn || msg.key.participantPn; // Quando remoteJid=@lid, remoteJidAlt contém @s.whatsapp.net (e vice-versa).
if (senderPn) { const remoteJidAlt = msg.key.remoteJidAlt;
jidRaw = (0, whatsapp_1.normalizeJid)(senderPn); if (remoteJidAlt && remoteJidAlt.endsWith('@s.whatsapp.net')) {
if (!jidRaw.endsWith('@s.whatsapp.net')) jidRaw = (0, whatsapp_1.normalizeJid)(remoteJidAlt);
continue;
} }
else { else {
// Fallback: busca mapeamento LID → phone já salvo no banco // 2. senderPn / participantPn
// (populado por phoneNumberShare ou processMessage anterior) const senderPn = msg.key.senderPn || msg.key.participantPn;
const contact = await prisma_1.prisma.contact.findFirst({ if (senderPn) {
where: { tenantId: this.tenantId, instanceId: this.instanceId, lidJid: jidRaw }, jidRaw = (0, whatsapp_1.normalizeJid)(senderPn);
select: { jid: true }, if (!jidRaw.endsWith('@s.whatsapp.net'))
}); continue;
if (contact?.jid && !contact.jid.endsWith('@lid')) {
jidRaw = contact.jid;
} }
else { else {
// Sem resolução possível, ou contato ainda armazenado com JID @lid // 3. DB lookup — mapeamento LID → phone salvo pelo ContactHandler
// (o mapeamento phone ainda não chegou via phoneNumberShare / senderPn). const contact = await prisma_1.prisma.contact.findFirst({
continue; where: { tenantId: this.tenantId, instanceId: this.instanceId, lidJid: jidRaw },
select: { jid: true },
});
if (contact?.jid && !contact.jid.endsWith('@lid')) {
jidRaw = contact.jid;
}
else {
// Sem resolução possível — descarta (será capturada via phoneNumberShare futuro)
continue;
}
} }
} }
} }
@@ -256,7 +334,7 @@ class MessageHandler {
if (!message || !key.id) if (!message || !key.id)
return []; return [];
message = unwrapInnerMessage(message); message = unwrapInnerMessage(message);
const contentType = (0, baileys_1.getContentType)(message); const contentType = (0, engine_1.getContentType)(message);
if (!contentType) if (!contentType)
return []; return [];
// Descarta tipos de protocolo/sistema que não representam conteúdo real // Descarta tipos de protocolo/sistema que não representam conteúdo real
@@ -301,7 +379,7 @@ class MessageHandler {
} }
} }
if (totalSaved > 0) { if (totalSaved > 0) {
logger_1.logger.info({ totalSaved, chats: byJid.size, instanceId: this.instanceId }, '[History] Mensagens históricas salvas'); logger_1.logger.info({ totalSaved, chats: byJid.size, instanceId: this.instanceId }, `[History] Lote de ${totalSaved} mensagens históricas salvas`);
} }
} }
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
@@ -365,7 +443,7 @@ class MessageHandler {
originalLidJid = key.remoteJid; originalLidJid = key.remoteJid;
key.remoteJid = phoneJid; key.remoteJid = phoneJid;
} }
const contentType = (0, baileys_1.getContentType)(message); const contentType = (0, engine_1.getContentType)(message);
if (!contentType) if (!contentType)
return; return;
// Descarta tipos de protocolo/sistema (sem conteúdo real para o usuário) // Descarta tipos de protocolo/sistema (sem conteúdo real para o usuário)
@@ -659,6 +737,8 @@ class MessageHandler {
: null, : null,
lastMessage, lastMessage,
}); });
// Invalida cache da listagem — força refetch na próxima request da inbox
(0, chat_cache_1.invalidateChatListCache)(this.tenantId, this.instanceId).catch(() => { });
} }
catch (err) { catch (err) {
logger_1.logger.error({ err, chatId }, '[MessageHandler] Falha ao emitir chat:upsert'); logger_1.logger.error({ err, chatId }, '[MessageHandler] Falha ao emitir chat:upsert');
@@ -678,7 +758,7 @@ class MessageHandler {
*/ */
async downloadAndPersistMedia(msg, savedMsgId, chatId, contentType) { async downloadAndPersistMedia(msg, savedMsgId, chatId, contentType) {
try { try {
const buffer = await (0, baileys_1.downloadMediaMessage)(msg, 'buffer', {}, { logger: logger_1.logger, reuploadRequest: this.sock.updateMediaMessage }); const buffer = await (0, engine_1.downloadMediaMessage)(msg, 'buffer', {}, { logger: logger_1.logger, reuploadRequest: this.sock.updateMediaMessage });
if (!buffer) if (!buffer)
return; return;
// Para documentos: preserva nome e extensão originais do arquivo // Para documentos: preserva nome e extensão originais do arquivo
File diff suppressed because one or more lines are too long
@@ -900,12 +900,46 @@ export class MessageHandler {
mediaUrl, mediaUrl,
}) })
// Persiste sticker na biblioteca (deduplicado por sha256)
if (contentType === 'stickerMessage') {
this.persistSticker(msg, mediaUrl).catch(() => {})
}
logger.debug({ savedMsgId, filePath }, 'Mídia salva') logger.debug({ savedMsgId, filePath }, 'Mídia salva')
} catch (err) { } catch (err) {
logger.error({ err, savedMsgId }, 'Falha ao baixar mídia') logger.error({ err, savedMsgId }, 'Falha ao baixar mídia')
} }
} }
private async persistSticker(msg: WAMessage, wasabiPath: string): Promise<void> {
const stickerMsg = msg.message?.stickerMessage
if (!stickerMsg) return
const sha256Bytes = stickerMsg.fileSha256
if (!sha256Bytes || sha256Bytes.length === 0) return
const sha256 = Buffer.from(sha256Bytes).toString('hex')
const isAnimated = stickerMsg.isAnimated ?? false
await prisma.sticker.upsert({
where: { tenantId_sha256: { tenantId: this.tenantId, sha256 } },
create: {
tenantId: this.tenantId,
instanceId: this.instanceId,
sha256,
wasabiPath,
isAnimated,
useCount: 1,
lastSeenAt: new Date(),
},
update: {
useCount: { increment: 1 },
lastSeenAt: new Date(),
instanceId: this.instanceId,
},
})
}
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
// UTILITÁRIOS // UTILITÁRIOS
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
@@ -31,6 +31,8 @@ import { adminRouter } from './modules/auth/admin.routes'
import { pairRouter } from './modules/pair/pair.routes' import { pairRouter } from './modules/pair/pair.routes'
import { buildTemplateRoutes } from './modules/templates/template.routes' import { buildTemplateRoutes } from './modules/templates/template.routes'
import { buildScheduledRoutes } from './modules/scheduled/scheduled.routes' import { buildScheduledRoutes } from './modules/scheduled/scheduled.routes'
import { buildStickerRoutes } from './modules/stickers/sticker.routes'
import { storageProvider } from './core/StorageProvider'
import { SchedulerService } from './modules/scheduled/scheduler.service' import { SchedulerService } from './modules/scheduled/scheduler.service'
import { authMiddleware } from './shared/middlewares/auth.middleware' import { authMiddleware } from './shared/middlewares/auth.middleware'
import { adminMiddleware } from './shared/middlewares/admin.middleware' import { adminMiddleware } from './shared/middlewares/admin.middleware'
@@ -87,6 +89,30 @@ async function bootstrap() {
const scheduler = new SchedulerService(whatsAppManager) const scheduler = new SchedulerService(whatsAppManager)
app.use('/api/templates', authMiddleware, buildTemplateRoutes()) app.use('/api/templates', authMiddleware, buildTemplateRoutes())
app.use('/api/scheduled', authMiddleware, buildScheduledRoutes(scheduler)) app.use('/api/scheduled', authMiddleware, buildScheduledRoutes(scheduler))
app.use('/api/stickers', authMiddleware, buildStickerRoutes())
// Proxy genérico para arquivos no Wasabi — usado pelo frontend para exibir stickers e mídias
app.get('/api/storage/view/*', authMiddleware, async (req, res) => {
const filePath = (req.params as any)[0] as string
if (!filePath) { res.status(400).end(); return }
try {
const buffer = await storageProvider.getBuffer(filePath)
if (!buffer) { res.status(404).end(); return }
const ext = filePath.split('.').pop()?.toLowerCase()
const mime =
ext === 'webp' ? 'image/webp' :
ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg' :
ext === 'png' ? 'image/png' :
ext === 'mp4' ? 'video/mp4' :
ext === 'ogg' ? 'audio/ogg' :
'application/octet-stream'
res.setHeader('Content-Type', mime)
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
res.send(buffer)
} catch {
res.status(500).end()
}
})
// Banner do frontend — dias restantes do plano // Banner do frontend — dias restantes do plano
app.get('/api/plan/status', authMiddleware, async (req, res) => { app.get('/api/plan/status', authMiddleware, async (req, res) => {
@@ -13,14 +13,18 @@ import {
LayoutTemplate, LayoutTemplate,
BookOpen, BookOpen,
Plus, Plus,
Sticker,
} from 'lucide-react'; } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import EmojiPicker, { Theme } from 'emoji-picker-react'; import Picker from '@emoji-mart/react';
import emojiData from '@emoji-mart/data';
import { groupApi, type GroupParticipant, type MessageTemplate, type TemplateType } from '../services/chatApiService'; import { groupApi, type GroupParticipant, type MessageTemplate, type TemplateType } from '../services/chatApiService';
import RichMessageComposer from './RichMessageComposer'; import RichMessageComposer from './RichMessageComposer';
import TemplateLibrary from './TemplateLibrary'; import TemplateLibrary from './TemplateLibrary';
import ScheduleMessageModal from './ScheduleMessageModal'; import ScheduleMessageModal from './ScheduleMessageModal';
import FilePreviewModal from './FilePreviewModal'; import FilePreviewModal from './FilePreviewModal';
import StickerPanel from './StickerPanel';
import { mediaApi } from '../services/chatApiService';
interface Message { interface Message {
id: number; id: number;
@@ -64,7 +68,9 @@ const InputBar: React.FC<InputBarProps> = ({
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [showEmojiPicker, setShowEmojiPicker] = useState(false); const [showEmojiPicker, setShowEmojiPicker] = useState(false);
const [showStickerPanel, setShowStickerPanel] = useState(false);
const [showAttachMenu, setShowAttachMenu] = useState(false); const [showAttachMenu, setShowAttachMenu] = useState(false);
const stickerPanelRef = useRef<HTMLDivElement>(null);
const [previewFile, setPreviewFile] = useState<File | null>(null); const [previewFile, setPreviewFile] = useState<File | null>(null);
const attachMenuRef = useRef<HTMLDivElement>(null); const attachMenuRef = useRef<HTMLDivElement>(null);
const [showRichComposer, setShowRichComposer] = useState(false); const [showRichComposer, setShowRichComposer] = useState(false);
@@ -181,12 +187,15 @@ const InputBar: React.FC<InputBarProps> = ({
} }
}, [newMessage]); }, [newMessage]);
// Close emoji picker and attach menu on click outside // Close emoji picker, sticker panel and attach menu on click outside
useEffect(() => { useEffect(() => {
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: MouseEvent) => {
if (emojiPickerRef.current && !emojiPickerRef.current.contains(event.target as Node)) { if (emojiPickerRef.current && !emojiPickerRef.current.contains(event.target as Node)) {
setShowEmojiPicker(false); setShowEmojiPicker(false);
} }
if (stickerPanelRef.current && !stickerPanelRef.current.contains(event.target as Node)) {
setShowStickerPanel(false);
}
if (attachMenuRef.current && !attachMenuRef.current.contains(event.target as Node)) { if (attachMenuRef.current && !attachMenuRef.current.contains(event.target as Node)) {
setShowAttachMenu(false); setShowAttachMenu(false);
} }
@@ -195,6 +204,12 @@ const InputBar: React.FC<InputBarProps> = ({
return () => document.removeEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside);
}, []); }, []);
const handleSendSticker = useCallback(async (blob: Blob) => {
if (!instanceId || !selectedChat?.remote_jid) return;
setShowStickerPanel(false);
await mediaApi.sendSticker(instanceId, selectedChat.remote_jid, blob);
}, [instanceId, selectedChat]);
const handleKeyDown = (e: React.KeyboardEvent) => { const handleKeyDown = (e: React.KeyboardEvent) => {
// Mention dropdown navigation // Mention dropdown navigation
if (mentionQuery !== null && filteredParticipants.length > 0) { if (mentionQuery !== null && filteredParticipants.length > 0) {
@@ -226,9 +241,8 @@ const InputBar: React.FC<InputBarProps> = ({
} }
}; };
const onEmojiClick = (emojiData: any) => { const onEmojiSelect = (emoji: any) => {
onNewMessageChange(newMessage + emojiData.emoji); onNewMessageChange(newMessage + (emoji.native ?? ''));
// Do not close picker, common UX allows multiple emojis
}; };
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -356,12 +370,36 @@ const InputBar: React.FC<InputBarProps> = ({
exit={{ opacity: 0, y: 10, scale: 0.95 }} exit={{ opacity: 0, y: 10, scale: 0.95 }}
className="absolute bottom-20 left-4 z-50 shadow-2xl rounded-2xl overflow-hidden border border-gray-100" className="absolute bottom-20 left-4 z-50 shadow-2xl rounded-2xl overflow-hidden border border-gray-100"
> >
<EmojiPicker <Picker
onEmojiClick={onEmojiClick} data={emojiData}
autoFocusSearch={false} onEmojiSelect={onEmojiSelect}
theme={Theme.LIGHT} locale="pt"
width={320} theme="light"
height={400} previewPosition="none"
skinTonePosition="none"
searchPosition="sticky"
navPosition="top"
perLine={8}
maxFrequentRows={2}
/>
</motion.div>
)}
</AnimatePresence>
{/* Sticker Panel Popover */}
<AnimatePresence>
{showStickerPanel && instanceId && (
<motion.div
ref={stickerPanelRef}
initial={{ opacity: 0, y: 10, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 10, scale: 0.95 }}
className="absolute bottom-20 left-16 z-50"
>
<StickerPanel
instanceId={instanceId}
jid={selectedChat?.remote_jid ?? ''}
onSend={handleSendSticker}
/> />
</motion.div> </motion.div>
)} )}
@@ -482,12 +520,23 @@ const InputBar: React.FC<InputBarProps> = ({
{/* Emoji — mantém no lugar */} {/* Emoji — mantém no lugar */}
<button <button
onClick={() => setShowEmojiPicker(!showEmojiPicker)} onClick={() => { setShowEmojiPicker(!showEmojiPicker); setShowStickerPanel(false); }}
className={`p-2 hover:bg-black/10 rounded-full transition-all active:scale-95 ${showEmojiPicker ? 'text-[#00a884]' : 'text-[#54656f]'}`} className={`p-2 hover:bg-black/10 rounded-full transition-all active:scale-95 ${showEmojiPicker ? 'text-[#00a884]' : 'text-[#54656f]'}`}
title="Emojis" title="Emojis"
> >
<Smile className="w-[26px] h-[26px]" /> <Smile className="w-[26px] h-[26px]" />
</button> </button>
{/* Figurinhas */}
{instanceId && (
<button
onClick={() => { setShowStickerPanel(!showStickerPanel); setShowEmojiPicker(false); }}
className={`p-2 hover:bg-black/10 rounded-full transition-all active:scale-95 ${showStickerPanel ? 'text-[#00a884]' : 'text-[#54656f]'}`}
title="Figurinhas"
>
<Sticker className="w-[24px] h-[24px]" />
</button>
)}
</div> </div>
<div className="flex-1 relative"> <div className="flex-1 relative">
@@ -0,0 +1,228 @@
'use client';
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Heart, Clock, Loader2 } from 'lucide-react';
import { stickerApi, type StickerRecord } from '../services/chatApiService';
import { getStickerFromCache, setStickerInCache } from '../hooks/useStickerCache';
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3003';
const LIST_VERSION_KEY = 'nw:sticker-list-version';
interface StickerPanelProps {
instanceId: string;
jid: string;
onSend: (blob: Blob) => void;
}
// Converte URL Wasabi → URL do proxy do backend
function stickerSrcUrl(wasabiPath: string): string {
const clean = wasabiPath.startsWith('/') ? wasabiPath.slice(1) : wasabiPath;
return `${API}/api/storage/view/${clean}`;
}
// Carrega sticker: tenta IndexedDB, depois proxy, depois persiste no cache
function useStickerBlob(sticker: StickerRecord) {
const [dataUrl, setDataUrl] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
getStickerFromCache(sticker.sha256).then((cached) => {
if (cancelled) return;
if (cached) { setDataUrl(cached); return; }
// Carrega do proxy e converte para dataURL via canvas
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
if (cancelled) return;
try {
const canvas = document.createElement('canvas');
canvas.width = img.naturalWidth || 96;
canvas.height = img.naturalHeight || 96;
const ctx = canvas.getContext('2d');
if (!ctx) { setDataUrl(img.src); return; }
ctx.drawImage(img, 0, 0);
const url = canvas.toDataURL('image/webp', 0.9);
setDataUrl(url);
setStickerInCache(sticker.sha256, url);
} catch {
setDataUrl(img.src);
}
};
img.onerror = () => { if (!cancelled) setDataUrl(null); };
img.src = stickerSrcUrl(sticker.wasabiPath);
});
return () => { cancelled = true; };
}, [sticker.sha256, sticker.wasabiPath]);
return dataUrl;
}
// Item individual de sticker
function StickerItem({
sticker,
onSend,
onToggleFavorite,
}: {
sticker: StickerRecord;
onSend: (s: StickerRecord) => void;
onToggleFavorite: (s: StickerRecord) => void;
}) {
const dataUrl = useStickerBlob(sticker);
const [hovering, setHovering] = useState(false);
return (
<div
className="relative group cursor-pointer"
onMouseEnter={() => setHovering(true)}
onMouseLeave={() => setHovering(false)}
onClick={() => dataUrl && onSend(sticker)}
>
<div className="w-16 h-16 flex items-center justify-center rounded-xl hover:bg-black/5 transition-all active:scale-90 p-1">
{dataUrl ? (
<img
src={dataUrl}
alt=""
className="w-full h-full object-contain"
loading="lazy"
/>
) : (
<div className="w-10 h-10 bg-gray-100 rounded-lg animate-pulse" />
)}
</div>
{/* Botão de favoritar — aparece no hover */}
{hovering && (
<button
className={`absolute top-0.5 right-0.5 p-0.5 rounded-full transition-all z-10 ${
sticker.isFavorite
? 'text-red-400 bg-white/90'
: 'text-gray-300 bg-white/80 hover:text-red-400'
}`}
onClick={(e) => { e.stopPropagation(); onToggleFavorite(sticker); }}
title={sticker.isFavorite ? 'Remover dos favoritos' : 'Favoritar'}
>
<Heart className="w-3 h-3" fill={sticker.isFavorite ? 'currentColor' : 'none'} />
</button>
)}
</div>
);
}
export default function StickerPanel({ instanceId, jid, onSend }: StickerPanelProps) {
const [tab, setTab] = useState<'recent' | 'favorites'>('recent');
const [stickers, setStickers] = useState<StickerRecord[]>([]);
const [loading, setLoading] = useState(true);
const fetchedRef = useRef(false);
const load = useCallback(async (force = false) => {
setLoading(true);
try {
const { listVersion, stickers: list } = await stickerApi.list();
if (!force) {
const cached = localStorage.getItem(LIST_VERSION_KEY);
if (cached === listVersion) {
// versão igual — sem novidades, mas ainda mostra o que temos
}
localStorage.setItem(LIST_VERSION_KEY, listVersion);
}
setStickers(list);
} catch {
// silencioso — sem stickers ainda
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (fetchedRef.current) return;
fetchedRef.current = true;
load();
}, [load]);
const handleSend = useCallback(async (sticker: StickerRecord) => {
const dataUrl = await getStickerFromCache(sticker.sha256);
if (!dataUrl) return;
// Converte dataURL → Blob → envia
const res = await fetch(dataUrl);
const blob = await res.blob();
onSend(blob);
}, [onSend]);
const handleToggleFavorite = useCallback(async (sticker: StickerRecord) => {
const result = await stickerApi.toggleFavorite(sticker.id);
setStickers((prev) =>
prev.map((s) => s.id === sticker.id ? { ...s, isFavorite: result.isFavorite } : s)
);
}, []);
const displayed =
tab === 'favorites'
? stickers.filter((s) => s.isFavorite)
: stickers; // recent = all sorted by lastSeenAt (backend already sorts)
return (
<div className="w-72 bg-white rounded-2xl shadow-2xl border border-gray-100 overflow-hidden">
{/* Tabs */}
<div className="flex border-b border-gray-100">
<button
onClick={() => setTab('recent')}
className={`flex-1 flex items-center justify-center gap-1.5 py-2.5 text-xs font-medium transition-colors ${
tab === 'recent' ? 'text-[#00a884] border-b-2 border-[#00a884]' : 'text-[#54656f] hover:bg-gray-50'
}`}
>
<Clock className="w-3.5 h-3.5" />
Recentes
</button>
<button
onClick={() => setTab('favorites')}
className={`flex-1 flex items-center justify-center gap-1.5 py-2.5 text-xs font-medium transition-colors ${
tab === 'favorites' ? 'text-[#00a884] border-b-2 border-[#00a884]' : 'text-[#54656f] hover:bg-gray-50'
}`}
>
<Heart className="w-3.5 h-3.5" />
Favoritos
</button>
</div>
{/* Content */}
<div className="h-[220px] overflow-y-auto p-2">
{loading ? (
<div className="flex items-center justify-center h-full text-gray-300">
<Loader2 className="w-6 h-6 animate-spin" />
</div>
) : displayed.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full gap-2 text-gray-300">
{tab === 'favorites' ? (
<>
<Heart className="w-8 h-8" />
<span className="text-xs">Nenhum favorito ainda</span>
</>
) : (
<>
<span className="text-3xl">🙂</span>
<span className="text-xs text-center">
As figurinhas que você receber<br />aparecem aqui automaticamente
</span>
</>
)}
</div>
) : (
<div className="grid grid-cols-4 gap-0.5">
{displayed.map((s) => (
<StickerItem
key={s.id}
sticker={s}
onSend={handleSend}
onToggleFavorite={handleToggleFavorite}
/>
))}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,82 @@
'use client'
/**
* useStickerCache — cache de figurinhas em IndexedDB.
*
* Chave de cache: sha256 hex do arquivo (conteúdo permanente — nunca muda).
* Sem TTL: stickers são imutáveis, então o cache é eterno.
* O único motivo para uma entrada sumir é o usuário limpar dados do navegador.
*
* Armazenamento: dataURL base64 do WebP (compatível com <img src>).
*/
const DB_NAME = 'nw-stickers'
const STORE_NAME = 'blobs'
const DB_VERSION = 1
interface CacheEntry {
sha256: string
dataUrl: string
}
let dbPromise: Promise<IDBDatabase> | null = null
function openDB(): Promise<IDBDatabase> {
if (dbPromise) return dbPromise
dbPromise = new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION)
req.onupgradeneeded = () => {
const db = req.result
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: 'sha256' })
}
}
req.onsuccess = () => resolve(req.result)
req.onerror = () => { dbPromise = null; reject(req.error) }
})
return dbPromise
}
export async function getStickerFromCache(sha256: string): Promise<string | null> {
if (typeof window === 'undefined') return null
try {
const db = await openDB()
return new Promise((resolve) => {
const tx = db.transaction(STORE_NAME, 'readonly')
const req = tx.objectStore(STORE_NAME).get(sha256)
req.onsuccess = () => resolve((req.result as CacheEntry | undefined)?.dataUrl ?? null)
req.onerror = () => resolve(null)
})
} catch {
return null
}
}
export async function setStickerInCache(sha256: string, dataUrl: string): Promise<void> {
if (typeof window === 'undefined') return
try {
const db = await openDB()
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite')
const entry: CacheEntry = { sha256, dataUrl }
const req = tx.objectStore(STORE_NAME).put(entry)
req.onsuccess = () => resolve()
req.onerror = () => reject(req.error)
})
} catch { /* quota exceeded — ignora */ }
}
export async function hasStickerInCache(sha256: string): Promise<boolean> {
if (typeof window === 'undefined') return false
try {
const db = await openDB()
return new Promise((resolve) => {
const tx = db.transaction(STORE_NAME, 'readonly')
const req = tx.objectStore(STORE_NAME).count(sha256)
req.onsuccess = () => resolve(req.result > 0)
req.onerror = () => resolve(false)
})
} catch {
return false
}
}
+22
View File
@@ -8,6 +8,8 @@
"name": "newwhats-dashboard", "name": "newwhats-dashboard",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"@xyflow/react": "^12.10.2", "@xyflow/react": "^12.10.2",
"axios": "^1.6.0", "axios": "^1.6.0",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
@@ -46,6 +48,20 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/@emoji-mart/data": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emoji-mart/data/-/data-1.2.1.tgz",
"integrity": "sha512-no2pQMWiBy6gpBEiqGeU77/bFejDqUTRY7KX+0+iur13op3bqUsXdnwoZs6Xb1zbv0gAj5VvS1PWoUUckSr5Dw=="
},
"node_modules/@emoji-mart/react": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@emoji-mart/react/-/react-1.1.1.tgz",
"integrity": "sha512-NMlFNeWgv1//uPsvLxvGQoIerPuVdXwK/EUek8OOkJ6wVOWPUizRBJU0hDqWZCOROVpfBgCemaC3m6jDOXi03g==",
"peerDependencies": {
"emoji-mart": "^5.2",
"react": "^16.8 || ^17 || ^18"
}
},
"node_modules/@jridgewell/gen-mapping": { "node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13", "version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -1354,6 +1370,12 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/emoji-mart": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/emoji-mart/-/emoji-mart-5.6.0.tgz",
"integrity": "sha512-eJp3QRe79pjwa+duv+n7+5YsNhRcMl812EcFVwrnRvYKoNPoQb5qxU8DG6Bgwji0akHdp6D4Ln6tYLG58MFSow==",
"peer": true
},
"node_modules/emoji-picker-react": { "node_modules/emoji-picker-react": {
"version": "4.18.0", "version": "4.18.0",
"resolved": "https://registry.npmjs.org/emoji-picker-react/-/emoji-picker-react-4.18.0.tgz", "resolved": "https://registry.npmjs.org/emoji-picker-react/-/emoji-picker-react-4.18.0.tgz",
@@ -9,6 +9,8 @@
"lint": "next lint" "lint": "next lint"
}, },
"dependencies": { "dependencies": {
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"@xyflow/react": "^12.10.2", "@xyflow/react": "^12.10.2",
"axios": "^1.6.0", "axios": "^1.6.0",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
@@ -127,15 +127,26 @@ export default function WhatsAppInboxPage() {
const [isMobile, setIsMobile] = useState(false) const [isMobile, setIsMobile] = useState(false)
const [isNewConvoModalOpen, setIsNewConvoModalOpen] = useState(false) const [isNewConvoModalOpen, setIsNewConvoModalOpen] = useState(false)
// Preloader: começa visível e some após o primeiro ciclo loading true→false // Preloader: aparece APENAS em hard reload (F5 / URL direta) — nunca em
// O delay de 400ms dá tempo ao browser iniciar o carregamento dos avatares // navegação client-side (router.push). Detectado por:
// antes do overlay sumir, evitando o flash de skeletons pulsando. // 1. performance.getEntriesByType('navigation')[0].type === 'reload'
const [pageReady, setPageReady] = useState(false) // 2. ausência do flag sessionStorage 'nw:inbox-loaded' (primeira visita na sessão)
// sessionStorage sobrevive a navegações client-side mas é zerado em nova aba.
const [pageReady, setPageReady] = useState<boolean>(() => {
if (typeof window === 'undefined') return false
const navEntry = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming | undefined
const isHardReload = navEntry ? navEntry.type === 'reload' : (performance.navigation?.type === 1)
if (isHardReload) return false
return sessionStorage.getItem('nw:inbox-loaded') === '1'
})
const prevLoadingRef = useRef(false) const prevLoadingRef = useRef(false)
const readyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null) const readyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => { useEffect(() => {
if (prevLoadingRef.current && !loading) { if (prevLoadingRef.current && !loading) {
readyTimerRef.current = setTimeout(() => setPageReady(true), 400) readyTimerRef.current = setTimeout(() => {
setPageReady(true)
sessionStorage.setItem('nw:inbox-loaded', '1')
}, 400)
} }
prevLoadingRef.current = loading prevLoadingRef.current = loading
return () => { if (readyTimerRef.current) clearTimeout(readyTimerRef.current) } return () => { if (readyTimerRef.current) clearTimeout(readyTimerRef.current) }
@@ -202,6 +202,17 @@ export const mediaApi = {
}) })
.then((r) => r.data) .then((r) => r.data)
}, },
sendSticker: (instanceId: string, jid: string, blob: Blob) => {
const fd = new FormData()
fd.append('file', blob, 'sticker.webp')
fd.append('jid', jid)
return api
.post(`/api/instances/${instanceId}/send-media`, fd, {
headers: { 'Content-Type': 'multipart/form-data' },
})
.then((r) => r.data)
},
} }
// ─── Chatbot / IA ───────────────────────────────────────────────────────────── // ─── Chatbot / IA ─────────────────────────────────────────────────────────────
@@ -517,3 +528,26 @@ export const scheduleApi = {
logs: (id: string) => logs: (id: string) =>
api.get(`/api/scheduled/${id}/logs`).then((r) => r.data) as Promise<ScheduleLog[]>, api.get(`/api/scheduled/${id}/logs`).then((r) => r.data) as Promise<ScheduleLog[]>,
} }
// ─── Stickers ─────────────────────────────────────────────────────────────────
export interface StickerRecord {
id: string
sha256: string
wasabiPath: string
isAnimated: boolean
isFavorite: boolean
useCount?: number
lastSeenAt?: string
}
export const stickerApi = {
list: () =>
api.get('/api/stickers').then((r) => r.data) as Promise<{ listVersion: string; stickers: StickerRecord[] }>,
recent: () =>
api.get('/api/stickers/recent').then((r) => r.data) as Promise<{ stickers: StickerRecord[] }>,
toggleFavorite: (id: string) =>
api.patch(`/api/stickers/${id}/favorite`).then((r) => r.data) as Promise<{ id: string; isFavorite: boolean }>,
}
@@ -292,10 +292,11 @@ class UnifiedStorageProviderPlugin {
} }
const safeBase = originalName.replace(/[^a-zA-Z0-9._-]/g, '_'); const safeBase = originalName.replace(/[^a-zA-Z0-9._-]/g, '_');
const fileName = `${Date.now()}-${safeBase}`; const fileName = `${Date.now()}-${safeBase}`;
// Path no bucket: whatsapp/{tenantId}/{instanceId}/{file} ou {category}/{file} // Path no bucket: customPath (quando fornecido) > whatsapp/{t}/{i}/{file} > {cat}/{file}
const relativePath = (category === 'whatsapp' && payload.usuarioSis && payload.sessionJID) const relativePath = payload.customPath
? `whatsapp/${payload.usuarioSis}/${payload.sessionJID}/${fileName}` ?? ((category === 'whatsapp' && payload.usuarioSis && payload.sessionJID)
: `${category}/${fileName}`; ? `whatsapp/${payload.usuarioSis}/${payload.sessionJID}/${fileName}`
: `${category}/${fileName}`);
if (!this.s3 || !this.bucket) { if (!this.s3 || !this.bucket) {
return this.saveToLocalFallback(relativePath, buffer, originalName, category); return this.saveToLocalFallback(relativePath, buffer, originalName, category);
} }
@@ -317,6 +318,26 @@ class UnifiedStorageProviderPlugin {
return this.saveToLocalFallback(relativePath, buffer, originalName, category); return this.saveToLocalFallback(relativePath, buffer, originalName, category);
} }
} }
async getBuffer(relativePath) {
// 1. Fallback local
const localPath = path_1.default.join(this.localFallbackDir, relativePath);
if (fs_1.default.existsSync(localPath)) {
return fs_1.default.readFileSync(localPath);
}
// 2. Wasabi
if (!this.s3 || !this.bucket)
return null;
try {
const result = await this.s3.send(new client_s3_1.GetObjectCommand({ Bucket: this.bucket, Key: relativePath }));
if (!result.Body)
return null;
const bytes = await result.Body.transformToByteArray();
return Buffer.from(bytes);
}
catch {
return null;
}
}
/** Salva buffer no diretório de fallback local, criando subpastas se necessário */ /** Salva buffer no diretório de fallback local, criando subpastas se necessário */
saveToLocalFallback(relativePath, buffer, originalName, _category) { saveToLocalFallback(relativePath, buffer, originalName, _category) {
const dest = path_1.default.join(this.localFallbackDir, relativePath); const dest = path_1.default.join(this.localFallbackDir, relativePath);
File diff suppressed because one or more lines are too long
+83
View File
@@ -0,0 +1,83 @@
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const versionFilePath = path.resolve(__dirname, 'version.json');
const deploysFilePath = path.resolve(__dirname, 'deploys.json');
// 1. Obter informações de commit do Git
let commitHash = 'unknown';
let commitMessage = 'unknown';
let author = 'unknown';
try {
commitHash = execSync('git rev-parse --short HEAD').toString().trim();
commitMessage = execSync('git log -1 --pretty=format:"%s"').toString().trim();
author = execSync('git log -1 --pretty=format:"%an"').toString().trim();
} catch (e) {
console.error('Falha ao obter detalhes do git:', e.message);
}
// 2. Carregar versão atual ou inicializar
let data = {
version: 'v1.0.0',
buildCount: 0,
lastDeploy: ''
};
if (fs.existsSync(versionFilePath)) {
try {
data = JSON.parse(fs.readFileSync(versionFilePath, 'utf8'));
} catch (e) {
console.error('Falha ao analisar version.json, reiniciando.');
}
}
// 3. Incrementar buildCount (+1) e atualizar versão v1.0.x
data.buildCount = (data.buildCount || 0) + 1;
data.version = `v1.0.${data.buildCount}`; // v1.+1
data.lastDeploy = new Date().toISOString();
data.lastCommit = {
hash: commitHash,
message: commitMessage,
author: author
};
// 4. Salvar version.json local
fs.writeFileSync(versionFilePath, JSON.stringify(data, null, 2), 'utf8');
// Copiar para a pasta pública do frontend para que fique exposto via HTTP
const publicVersionPath = path.resolve(__dirname, 'frontend/public/version.json');
fs.mkdirSync(path.dirname(publicVersionPath), { recursive: true });
fs.writeFileSync(publicVersionPath, JSON.stringify(data, null, 2), 'utf8');
// 5. Adicionar entrada de histórico no deploys.json
let deploys = [];
if (fs.existsSync(deploysFilePath)) {
try {
deploys = JSON.parse(fs.readFileSync(deploysFilePath, 'utf8'));
} catch (e) {
console.error('Falha ao analisar deploys.json');
}
}
deploys.unshift({
version: data.version,
buildCount: data.buildCount,
timestamp: data.lastDeploy,
commitHash,
commitMessage,
author,
status: 'Sucesso'
});
// Limitar o histórico de deploys a 30 registros
deploys = deploys.slice(0, 30);
fs.writeFileSync(deploysFilePath, JSON.stringify(deploys, null, 2), 'utf8');
// Copiar deploys.json histórico para a pasta pública do frontend
const publicDeploysPath = path.resolve(__dirname, 'frontend/public/deploys.json');
fs.writeFileSync(publicDeploysPath, JSON.stringify(deploys, null, 2), 'utf8');
console.log(`Versão atualizada para ${data.version} (build #${data.buildCount})`);