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:
@@ -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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"}
|
||||||
+68
-7
@@ -11,42 +11,103 @@ 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) {
|
||||||
|
try {
|
||||||
const raw = await this.client.get(key);
|
const raw = await this.client.get(key);
|
||||||
if (!raw)
|
if (!raw)
|
||||||
return null;
|
return null;
|
||||||
return JSON.parse(raw);
|
return JSON.parse(raw);
|
||||||
}
|
}
|
||||||
|
catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
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) {
|
||||||
|
try {
|
||||||
await this.client.del(key);
|
await this.client.del(key);
|
||||||
}
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
async exists(key) {
|
async exists(key) {
|
||||||
|
try {
|
||||||
const count = await this.client.exists(key);
|
const count = await this.client.exists(key);
|
||||||
return count > 0;
|
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) {
|
||||||
|
try {
|
||||||
await this.client.publish(channel, message);
|
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":";;;;;;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,wFAAwF;YACxF,oBAAoB,EAAE,IAAI;YAC1B,gBAAgB,EAAE,KAAK;YACvB,WAAW,EAAE,IAAI;YACjB,sEAAsE;YACtE,kBAAkB,EAAE,KAAK;YACzB,mDAAmD;YACnD,aAAa,CAAC,KAAa;gBACzB,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,GAAG,GAAG,CAAC,IAAI,KAAK,EAAE,KAAM,CAAC,CAAA;gBAChD,OAAO,KAAK,CAAA;YACd,CAAC;SACF,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,EAAI,GAAG,EAAE,CAAC,eAAM,CAAC,IAAI,CAAC,oBAAoB,CAAC,CAAC,CAAA;QAClE,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC,OAAO,EAAI,CAAC,GAAG,EAAE,EAAE,CAAC,eAAM,CAAC,KAAK,CAAC,EAAE,GAAG,EAAE,kBAAkB,EAAE,IAAI,EAAG,GAAW,CAAC,IAAI,EAAE,CAAC,CAAC,CAAA;QACtG,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC,OAAO,EAAI,GAAG,EAAE,CAAC,eAAM,CAAC,IAAI,CAAC,+CAA+C,CAAC,CAAC,CAAA;IAC/F,CAAC;IAED,gDAAgD;IAChD,OAAO;QACL,OAAO,IAAI,CAAC,MAAM,CAAC,MAAM,KAAK,OAAO,CAAA;IACvC,CAAC;IAED,KAAK,CAAC,OAAO;QACX,8EAA8E;QAC9E,8CAA8C;QAC9C,IAAI,CAAC,MAAM,CAAC,OAAO,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,GAAmD,CAAC,CAAC,CAAA;IACxF,CAAC;IAED,KAAK,CAAC,GAAG,CAAC,GAAW;QACnB,IAAI,CAAC;YAAC,OAAO,MAAM,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC,CAAA;QAAC,CAAC;QAAC,MAAM,CAAC;YAAC,OAAO,IAAI,CAAA;QAAC,CAAC;IACjE,CAAC;IAED,KAAK,CAAC,OAAO,CAAI,GAAW;QAC1B,IAAI,CAAC;YACH,MAAM,GAAG,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC,CAAA;YACtC,IAAI,CAAC,GAAG;gBAAE,OAAO,IAAI,CAAA;YACrB,OAAO,IAAI,CAAC,KAAK,CAAC,GAAG,CAAM,CAAA;QAC7B,CAAC;QAAC,MAAM,CAAC;YAAC,OAAO,IAAI,CAAA;QAAC,CAAC;IACzB,CAAC;IAED,KAAK,CAAC,GAAG,CAAC,GAAW,EAAE,KAAa,EAAE,UAAmB;QACvD,IAAI,CAAC;YACH,MAAM,GAAG,GAAG,UAAU,IAAI,IAAI,CAAC,GAAG,CAAA;YAClC,IAAI,GAAG,GAAG,CAAC,EAAE,CAAC;gBACZ,MAAM,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,EAAE,KAAK,EAAE,IAAI,EAAE,GAAG,CAAC,CAAA;YAC9C,CAAC;iBAAM,CAAC;gBACN,MAAM,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,EAAE,KAAK,CAAC,CAAA;YACnC,CAAC;QACH,CAAC;QAAC,MAAM,CAAC,CAAC,iDAAiD,CAAC,CAAC;IAC/D,CAAC;IAED,KAAK,CAAC,OAAO,CAAC,GAAW,EAAE,KAAc,EAAE,UAAmB;QAC5D,IAAI,CAAC;YACH,MAAM,GAAG,GAAG,UAAU,IAAI,IAAI,CAAC,GAAG,CAAA;YAClC,IAAI,GAAG,GAAG,CAAC,EAAE,CAAC;gBACZ,MAAM,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,EAAE,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,EAAE,IAAI,EAAE,GAAG,CAAC,CAAA;YAC9D,CAAC;iBAAM,CAAC;gBACN,MAAM,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,EAAE,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC,CAAA;YACnD,CAAC;QACH,CAAC;QAAC,MAAM,CAAC,CAAC,iDAAiD,CAAC,CAAC;IAC/D,CAAC;IAED,KAAK,CAAC,MAAM,CAAC,GAAW,EAAE,KAAa,EAAE,KAAa;QACpD,IAAI,CAAC;YAAC,MAAM,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,GAAG,EAAE,KAAK,EAAE,KAAK,CAAC,CAAA;QAAC,CAAC;QAAC,MAAM,CAAC,CAAC,CAAC;IAC/D,CAAC;IAED,KAAK,CAAC,GAAG,CAAC,GAAW;QACnB,IAAI,CAAC;YAAC,MAAM,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC,CAAA;QAAC,CAAC;QAAC,MAAM,CAAC,CAAC,CAAC;IAC9C,CAAC;IAED,KAAK,CAAC,MAAM,CAAC,GAAW;QACtB,IAAI,CAAC;YACH,MAAM,KAAK,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,GAAG,CAAC,CAAA;YAC3C,OAAO,KAAK,GAAG,CAAC,CAAA;QAClB,CAAC;QAAC,MAAM,CAAC;YAAC,OAAO,KAAK,CAAA;QAAC,CAAC;IAC1B,CAAC;IAED,yEAAyE;IACzE,KAAK,CAAC,OAAO,CAAC,OAAe,EAAE,OAAe;QAC5C,IAAI,CAAC;YAAC,MAAM,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,OAAO,EAAE,OAAO,CAAC,CAAA;QAAC,CAAC;QAAC,MAAM,CAAC,CAAC,CAAC;IAC/D,CAAC;IAED,GAAG;QACD,OAAO,IAAI,CAAC,MAAM,CAAA;IACpB,CAAC;CACF;AAEY,QAAA,SAAS,GAAG,IAAI,eAAe,EAAE,CAAA"}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
+53
-51
@@ -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)
|
||||||
|
|||||||
+1
-1
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,8 +253,20 @@ 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.
|
||||||
|
// Quando remoteJid=@lid, remoteJidAlt contém @s.whatsapp.net (e vice-versa).
|
||||||
|
const remoteJidAlt = msg.key.remoteJidAlt;
|
||||||
|
if (remoteJidAlt && remoteJidAlt.endsWith('@s.whatsapp.net')) {
|
||||||
|
jidRaw = (0, whatsapp_1.normalizeJid)(remoteJidAlt);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// 2. senderPn / participantPn
|
||||||
const senderPn = msg.key.senderPn || msg.key.participantPn;
|
const senderPn = msg.key.senderPn || msg.key.participantPn;
|
||||||
if (senderPn) {
|
if (senderPn) {
|
||||||
jidRaw = (0, whatsapp_1.normalizeJid)(senderPn);
|
jidRaw = (0, whatsapp_1.normalizeJid)(senderPn);
|
||||||
@@ -195,8 +274,7 @@ class MessageHandler {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
// Fallback: busca mapeamento LID → phone já salvo no banco
|
// 3. DB lookup — mapeamento LID → phone salvo pelo ContactHandler
|
||||||
// (populado por phoneNumberShare ou processMessage anterior)
|
|
||||||
const contact = await prisma_1.prisma.contact.findFirst({
|
const contact = await prisma_1.prisma.contact.findFirst({
|
||||||
where: { tenantId: this.tenantId, instanceId: this.instanceId, lidJid: jidRaw },
|
where: { tenantId: this.tenantId, instanceId: this.instanceId, lidJid: jidRaw },
|
||||||
select: { jid: true },
|
select: { jid: true },
|
||||||
@@ -205,12 +283,12 @@ class MessageHandler {
|
|||||||
jidRaw = contact.jid;
|
jidRaw = contact.jid;
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
// Sem resolução possível, ou contato ainda armazenado com JID @lid
|
// Sem resolução possível — descarta (será capturada via phoneNumberShare futuro)
|
||||||
// (o mapeamento phone ainda não chegou via phoneNumberShare / senderPn).
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
const jid = (0, whatsapp_1.normalizeJid)(jidRaw);
|
const jid = (0, whatsapp_1.normalizeJid)(jidRaw);
|
||||||
const list = byJid.get(jid) ?? [];
|
const list = byJid.get(jid) ?? [];
|
||||||
list.push(msg);
|
list.push(msg);
|
||||||
@@ -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
@@ -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
|
||||||
|
?? ((category === 'whatsapp' && payload.usuarioSis && payload.sessionJID)
|
||||||
? `whatsapp/${payload.usuarioSis}/${payload.sessionJID}/${fileName}`
|
? `whatsapp/${payload.usuarioSis}/${payload.sessionJID}/${fileName}`
|
||||||
: `${category}/${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
@@ -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})`);
|
||||||
Reference in New Issue
Block a user