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
|
||||
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 query string fix
|
||||
|
||||
@@ -11,9 +11,16 @@ class StorageProviderRegistry {
|
||||
constructor() {
|
||||
this.provider = null;
|
||||
this.healthy = false;
|
||||
this.lastHealthStatus = {
|
||||
healthy: false,
|
||||
configured: false,
|
||||
reason: 'not_configured',
|
||||
message: 'Storage não inicializado.',
|
||||
};
|
||||
}
|
||||
register(provider) {
|
||||
this.provider = provider;
|
||||
this.lastHealthStatus = { healthy: true, configured: true, reason: 'ok', message: 'Wasabi inicializado.' };
|
||||
logger_1.logger.info('[StorageProvider] Provider registrado');
|
||||
}
|
||||
isRegistered() {
|
||||
@@ -22,6 +29,13 @@ class StorageProviderRegistry {
|
||||
isHealthy() {
|
||||
return this.healthy;
|
||||
}
|
||||
getLastHealthStatus() {
|
||||
return this.lastHealthStatus;
|
||||
}
|
||||
setHealthStatus(status) {
|
||||
this.lastHealthStatus = status;
|
||||
this.healthy = status.healthy;
|
||||
}
|
||||
async uploadFile(payload) {
|
||||
if (!this.provider) {
|
||||
throw new Error('Nenhum storage provider registrado. Ative o plugin UnifiedStorageProvider.');
|
||||
@@ -32,15 +46,34 @@ class StorageProviderRegistry {
|
||||
async updateHealth() {
|
||||
if (!this.provider) {
|
||||
this.healthy = false;
|
||||
this.lastHealthStatus = {
|
||||
healthy: false,
|
||||
configured: false,
|
||||
reason: 'not_configured',
|
||||
message: 'Storage não configurado. Acesse Admin > Plugins > UnifiedStorageProvider.',
|
||||
};
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.healthy = await this.provider.checkHealth();
|
||||
const status = await this.provider.checkDetailedHealth();
|
||||
this.healthy = status.healthy;
|
||||
this.lastHealthStatus = status;
|
||||
}
|
||||
catch {
|
||||
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) {
|
||||
if (!this.provider) {
|
||||
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() {
|
||||
this.ttl = parseInt(env_1.env.DRAGONFLY_TTL_SECONDS, 10);
|
||||
this.client = new ioredis_1.default(env_1.env.DRAGONFLY_URL, {
|
||||
maxRetriesPerRequest: 3,
|
||||
enableReadyCheck: true,
|
||||
// null = não limita tentativas por comando; a reconexão é gerenciada pelo retryStrategy
|
||||
maxRetriesPerRequest: null,
|
||||
enableReadyCheck: false,
|
||||
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('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() {
|
||||
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) {
|
||||
return this.client.get(key);
|
||||
try {
|
||||
return await this.client.get(key);
|
||||
}
|
||||
catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
async getJson(key) {
|
||||
try {
|
||||
const raw = await this.client.get(key);
|
||||
if (!raw)
|
||||
return null;
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
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) {
|
||||
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) {
|
||||
try {
|
||||
await this.client.del(key);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
async exists(key) {
|
||||
try {
|
||||
const count = await this.client.exists(key);
|
||||
return count > 0;
|
||||
}
|
||||
catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
/** Publica em um canal pub/sub (usado para broadcasting de QR/status) */
|
||||
async publish(channel, message) {
|
||||
try {
|
||||
await this.client.publish(channel, message);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
raw() {
|
||||
return this.client;
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"version":3,"file":"dragonfly.js","sourceRoot":"","sources":["dragonfly.ts"],"names":[],"mappings":";;;;;;AAAA,sDAA2B;AAC3B,0CAAsC;AACtC,gDAA4C;AAE5C,MAAM,eAAe;IAInB;QACE,IAAI,CAAC,GAAG,GAAG,QAAQ,CAAC,SAAG,CAAC,qBAAqB,EAAE,EAAE,CAAC,CAAA;QAElD,IAAI,CAAC,MAAM,GAAG,IAAI,iBAAK,CAAC,SAAG,CAAC,aAAa,EAAE;YACzC,oBAAoB,EAAE,CAAC;YACvB,gBAAgB,EAAE,IAAI;YACtB,WAAW,EAAE,IAAI;SAClB,CAAC,CAAA;QAEF,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC,SAAS,EAAE,GAAG,EAAE,CAAC,eAAM,CAAC,IAAI,CAAC,uBAAuB,CAAC,CAAC,CAAA;QACrE,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC,OAAO,EAAE,CAAC,GAAG,EAAE,EAAE,CAAC,eAAM,CAAC,KAAK,CAAC,GAAG,EAAE,kBAAkB,CAAC,CAAC,CAAA;IACzE,CAAC;IAED,KAAK,CAAC,OAAO;QACX,MAAM,IAAI,CAAC,MAAM,CAAC,OAAO,EAAE,CAAA;IAC7B,CAAC;IAED,KAAK,CAAC,GAAG,CAAC,GAAW;QACnB,OAAO,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC,CAAA;IAC7B,CAAC;IAED,KAAK,CAAC,OAAO,CAAI,GAAW;QAC1B,MAAM,GAAG,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC,CAAA;QACtC,IAAI,CAAC,GAAG;YAAE,OAAO,IAAI,CAAA;QACrB,OAAO,IAAI,CAAC,KAAK,CAAC,GAAG,CAAM,CAAA;IAC7B,CAAC;IAED,KAAK,CAAC,GAAG,CAAC,GAAW,EAAE,KAAa,EAAE,UAAmB;QACvD,MAAM,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,EAAE,KAAK,EAAE,IAAI,EAAE,UAAU,IAAI,IAAI,CAAC,GAAG,CAAC,CAAA;IACjE,CAAC;IAED,KAAK,CAAC,OAAO,CAAC,GAAW,EAAE,KAAc,EAAE,UAAmB;QAC5D,MAAM,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,EAAE,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,EAAE,IAAI,EAAE,UAAU,IAAI,IAAI,CAAC,GAAG,CAAC,CAAA;IACjF,CAAC;IAED,KAAK,CAAC,GAAG,CAAC,GAAW;QACnB,MAAM,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC,CAAA;IAC5B,CAAC;IAED,KAAK,CAAC,MAAM,CAAC,GAAW;QACtB,MAAM,KAAK,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,GAAG,CAAC,CAAA;QAC3C,OAAO,KAAK,GAAG,CAAC,CAAA;IAClB,CAAC;IAED,yEAAyE;IACzE,KAAK,CAAC,OAAO,CAAC,OAAe,EAAE,OAAe;QAC5C,MAAM,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,OAAO,EAAE,OAAO,CAAC,CAAA;IAC7C,CAAC;IAED,GAAG;QACD,OAAO,IAAI,CAAC,MAAM,CAAA;IACpB,CAAC;CACF;AAEY,QAAA,SAAS,GAAG,IAAI,eAAe,EAAE,CAAA"}
|
||||
{"version":3,"file":"dragonfly.js","sourceRoot":"","sources":["dragonfly.ts"],"names":[],"mappings":"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"}
|
||||
@@ -0,0 +1,44 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.invalidateChatListCache = invalidateChatListCache;
|
||||
/**
|
||||
* chat.cache.ts — Invalidação centralizada do cache da listagem de chats.
|
||||
*
|
||||
* O cache é populado em GET /api/chats com TTL de 30s.
|
||||
* Esta função invalida todas as variações (archived, limit) para um par
|
||||
* tenant+instance — uma nova mensagem ou alteração no chat força refetch.
|
||||
*
|
||||
* Usa SCAN ao invés de KEYS para não bloquear o DragonflyDB em prod.
|
||||
*/
|
||||
const dragonfly_1 = require("../../infra/cache/dragonfly");
|
||||
const logger_1 = require("../../config/logger");
|
||||
/**
|
||||
* Remove todas as chaves de cache da listagem de chats para um instance.
|
||||
* Pattern: chats:list:{tenantId}:{instanceId}:*
|
||||
* Operação fire-and-forget: erros são logados mas não propagados.
|
||||
*/
|
||||
async function invalidateChatListCache(tenantId, instanceId) {
|
||||
if (!dragonfly_1.dragonfly.isAlive())
|
||||
return;
|
||||
try {
|
||||
const client = dragonfly_1.dragonfly.raw();
|
||||
const pattern = `chats:list:${tenantId}:${instanceId}:*`;
|
||||
const stream = client.scanStream({ match: pattern, count: 100 });
|
||||
const keys = [];
|
||||
await new Promise((resolve, reject) => {
|
||||
stream.on('data', (batch) => {
|
||||
for (const k of batch)
|
||||
keys.push(k);
|
||||
});
|
||||
stream.on('end', () => resolve());
|
||||
stream.on('error', (err) => reject(err));
|
||||
});
|
||||
if (keys.length > 0) {
|
||||
await client.del(...keys);
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
logger_1.logger.warn({ err, tenantId, instanceId }, '[chat.cache] Falha ao invalidar cache (ignorado)');
|
||||
}
|
||||
}
|
||||
//# sourceMappingURL=chat.cache.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"chat.cache.js","sourceRoot":"","sources":["chat.cache.ts"],"names":[],"mappings":";;AAiBA,0DAsBC;AAvCD;;;;;;;;GAQG;AACH,2DAAuD;AACvD,gDAA4C;AAE5C;;;;GAIG;AACI,KAAK,UAAU,uBAAuB,CAAC,QAAgB,EAAE,UAAkB;IAChF,IAAI,CAAC,qBAAS,CAAC,OAAO,EAAE;QAAE,OAAM;IAChC,IAAI,CAAC;QACH,MAAM,MAAM,GAAG,qBAAS,CAAC,GAAG,EAAE,CAAA;QAC9B,MAAM,OAAO,GAAG,cAAc,QAAQ,IAAI,UAAU,IAAI,CAAA;QACxD,MAAM,MAAM,GAAG,MAAM,CAAC,UAAU,CAAC,EAAE,KAAK,EAAE,OAAO,EAAE,KAAK,EAAE,GAAG,EAAE,CAAC,CAAA;QAChE,MAAM,IAAI,GAAa,EAAE,CAAA;QAEzB,MAAM,IAAI,OAAO,CAAO,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;YAC1C,MAAM,CAAC,EAAE,CAAC,MAAM,EAAE,CAAC,KAAe,EAAE,EAAE;gBACpC,KAAK,MAAM,CAAC,IAAI,KAAK;oBAAE,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;YACrC,CAAC,CAAC,CAAA;YACF,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,GAAG,EAAE,CAAC,OAAO,EAAE,CAAC,CAAA;YACjC,MAAM,CAAC,EAAE,CAAC,OAAO,EAAE,CAAC,GAAG,EAAE,EAAE,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAA;QAC1C,CAAC,CAAC,CAAA;QAEF,IAAI,IAAI,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;YACpB,MAAM,MAAM,CAAC,GAAG,CAAC,GAAG,IAAI,CAAC,CAAA;QAC3B,CAAC;IACH,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,eAAM,CAAC,IAAI,CAAC,EAAE,GAAG,EAAE,QAAQ,EAAE,UAAU,EAAE,EAAE,kDAAkD,CAAC,CAAA;IAChG,CAAC;AACH,CAAC"}
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Rotas de Figurinhas (Stickers)
|
||||
*
|
||||
* GET /api/stickers — Lista stickers do tenant (com listVersion)
|
||||
* GET /api/stickers/recent — Últimos 30 stickers usados
|
||||
* PATCH /api/stickers/:id/favorite — Toggle isFavorite
|
||||
*/
|
||||
import { Router, type Request, type Response } from 'express'
|
||||
import { createHash } from 'crypto'
|
||||
import { prisma } from '../../infra/database/prisma'
|
||||
|
||||
export function buildStickerRoutes(): Router {
|
||||
const router = Router()
|
||||
|
||||
// ── LIST (com listVersion para cache invalidation) ────────────────────────
|
||||
router.get('/', async (req: Request, res: Response) => {
|
||||
const tenantId = req.tenantId!
|
||||
|
||||
const stickers = await prisma.sticker.findMany({
|
||||
where: { tenantId },
|
||||
orderBy: [{ isFavorite: 'desc' }, { lastSeenAt: 'desc' }],
|
||||
select: {
|
||||
id: true,
|
||||
sha256: true,
|
||||
wasabiPath: true,
|
||||
isAnimated: true,
|
||||
isFavorite: true,
|
||||
useCount: true,
|
||||
lastSeenAt: true,
|
||||
},
|
||||
})
|
||||
|
||||
// listVersion muda apenas quando o conjunto de stickers muda
|
||||
// (novo sha256 chega ou estado de favorito muda)
|
||||
const versionSource = stickers.length === 0
|
||||
? 'empty'
|
||||
: `${stickers.length}:${stickers[0]!.sha256}:${stickers[0]!.lastSeenAt.getTime()}:${stickers.filter(s => s.isFavorite).length}`
|
||||
const listVersion = createHash('md5').update(versionSource).digest('hex').slice(0, 8)
|
||||
|
||||
res.json({ listVersion, stickers })
|
||||
})
|
||||
|
||||
// ── RECENT (últimos 30) ───────────────────────────────────────────────────
|
||||
router.get('/recent', async (req: Request, res: Response) => {
|
||||
const tenantId = req.tenantId!
|
||||
|
||||
const stickers = await prisma.sticker.findMany({
|
||||
where: { tenantId },
|
||||
orderBy: { lastSeenAt: 'desc' },
|
||||
take: 30,
|
||||
select: {
|
||||
id: true,
|
||||
sha256: true,
|
||||
wasabiPath: true,
|
||||
isAnimated: true,
|
||||
isFavorite: true,
|
||||
},
|
||||
})
|
||||
|
||||
res.json({ stickers })
|
||||
})
|
||||
|
||||
// ── TOGGLE FAVORITE ───────────────────────────────────────────────────────
|
||||
router.patch('/:id/favorite', async (req: Request, res: Response) => {
|
||||
const tenantId = req.tenantId!
|
||||
const idParam = req.params['id']
|
||||
const id = Array.isArray(idParam) ? idParam[0]! : idParam
|
||||
|
||||
const sticker = await prisma.sticker.findFirst({
|
||||
where: { id, tenantId },
|
||||
select: { id: true, isFavorite: true },
|
||||
})
|
||||
|
||||
if (!sticker) {
|
||||
res.status(404).json({ error: 'Sticker não encontrado' })
|
||||
return
|
||||
}
|
||||
|
||||
const updated = await prisma.sticker.update({
|
||||
where: { id: sticker.id },
|
||||
data: { isFavorite: !sticker.isFavorite },
|
||||
select: { id: true, isFavorite: true },
|
||||
})
|
||||
|
||||
res.json(updated)
|
||||
})
|
||||
|
||||
return router
|
||||
}
|
||||
+53
-51
@@ -39,7 +39,7 @@ exports.WhatsAppConnectionManager = void 0;
|
||||
const path_1 = __importDefault(require("path"));
|
||||
const promises_1 = __importDefault(require("fs/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 env_1 = require("../../../config/env");
|
||||
const logger_1 = require("../../../config/logger");
|
||||
@@ -50,6 +50,7 @@ const MessageHandler_1 = require("../handlers/MessageHandler");
|
||||
const ContactHandler_1 = require("../handlers/ContactHandler");
|
||||
const chatbot_service_1 = require("../../chatbot/chatbot.service");
|
||||
const hook_bus_1 = require("../../../core/hook-bus");
|
||||
const chat_cache_1 = require("../../chats/chat.cache");
|
||||
// ─── Chaves de cache no DragonflyDB (Redis-compatible) ──────────────────────
|
||||
const CACHE_KEY_STATUS = (instanceId) => `instance:${instanceId}:status`;
|
||||
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
|
||||
const WATCHDOG_INTERVAL_MS = 180000; // verifica a cada 3 min
|
||||
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
|
||||
class WhatsAppConnectionManager {
|
||||
constructor(io) {
|
||||
@@ -113,6 +115,8 @@ class WhatsAppConnectionManager {
|
||||
this.lastWatchdogPing = new Map();
|
||||
/** Mapeamento instanceId → tenantId (necessário para reconexões) */
|
||||
this.tenantByInstance = new Map();
|
||||
/** Engine ativa por instância (usada pelo watchdog para ajustar tolerâncias) */
|
||||
this.engineByInstance = new Map();
|
||||
this.io = io;
|
||||
this.chatbotService = new chatbot_service_1.ChatbotService(io);
|
||||
this.startNetworkProbe();
|
||||
@@ -163,12 +167,23 @@ class WhatsAppConnectionManager {
|
||||
this.io.to(`tenant:${tenantId}`).emit('instance:status', { instanceId, status: client_1.InstanceStatus.DISCONNECTED });
|
||||
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
|
||||
// se não houver internet. Usa versão embutida como fallback seguro.
|
||||
let version = [2, 3000, 1023504162];
|
||||
try {
|
||||
const fetched = await (0, baileys_1.fetchLatestBaileysVersion)();
|
||||
const fetched = await fetchLatestBaileysVersion();
|
||||
version = fetched.version;
|
||||
}
|
||||
catch (err) {
|
||||
@@ -187,12 +202,12 @@ class WhatsAppConnectionManager {
|
||||
},
|
||||
debug: () => { }, info: () => { }, warn: () => { }, error: () => { }, fatal: () => { },
|
||||
};
|
||||
const sock = (0, baileys_1.makeWASocket)({
|
||||
const sock = makeWASocket({
|
||||
version,
|
||||
auth: {
|
||||
creds: state.creds,
|
||||
// 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,
|
||||
qrTimeout: 60000,
|
||||
@@ -215,15 +230,7 @@ class WhatsAppConnectionManager {
|
||||
});
|
||||
this.sockets.set(instanceId, sock);
|
||||
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);
|
||||
// ─── Estado de conexão ───────────────────────────────────────────────
|
||||
// Eventos: qr (novo QR gerado), open (conectado), close (desconectado),
|
||||
// connecting (tentando conectar)
|
||||
sock.ev.on('connection.update', (update) => {
|
||||
this.touchActivity(instanceId);
|
||||
this.baileysLog(tenantId, instanceId, 'connection.update', {
|
||||
@@ -281,9 +288,6 @@ class WhatsAppConnectionManager {
|
||||
// Delega processamento completo para o MessageHandler
|
||||
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) => {
|
||||
this.touchActivity(instanceId);
|
||||
for (const { key, update } of updates) {
|
||||
@@ -332,16 +336,6 @@ class WhatsAppConnectionManager {
|
||||
this.baileysLog(tenantId, instanceId, 'contacts.update', { count: contacts.length });
|
||||
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 }) => {
|
||||
this.touchActivity(instanceId);
|
||||
logger_1.logger.info({ instanceId, lid, phoneJid }, '[LID] phoneNumberShare recebido');
|
||||
@@ -359,9 +353,6 @@ class WhatsAppConnectionManager {
|
||||
}).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) => {
|
||||
this.touchActivity(instanceId);
|
||||
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.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) => {
|
||||
this.touchActivity(instanceId);
|
||||
this.baileysLog(tenantId, instanceId, 'groups.upsert', { count: groups.length });
|
||||
@@ -397,10 +384,6 @@ class WhatsAppConnectionManager {
|
||||
.map((g) => ({ id: g.id, name: g.subject }));
|
||||
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 }) => {
|
||||
this.touchActivity(instanceId);
|
||||
logger_1.logger.info({
|
||||
@@ -534,10 +517,11 @@ class WhatsAppConnectionManager {
|
||||
this.sockets.delete(instanceId);
|
||||
this.contactHandlers.delete(instanceId);
|
||||
this.msgHandlers.delete(instanceId);
|
||||
this.engineByInstance.delete(instanceId);
|
||||
this.stopWatchdog(instanceId);
|
||||
// Decide se deve reconectar baseado no código de erro
|
||||
const shouldReconnect = statusCode !== baileys_1.DisconnectReason.loggedOut &&
|
||||
statusCode !== baileys_1.DisconnectReason.forbidden;
|
||||
const shouldReconnect = statusCode !== engine_1.DisconnectReason.loggedOut &&
|
||||
statusCode !== engine_1.DisconnectReason.forbidden;
|
||||
if (shouldReconnect) {
|
||||
const attempts = (this.reconnectAttempts.get(instanceId) ?? 0) + 1;
|
||||
this.reconnectAttempts.set(instanceId, attempts);
|
||||
@@ -562,7 +546,7 @@ class WhatsAppConnectionManager {
|
||||
}
|
||||
else {
|
||||
// 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');
|
||||
await this.deleteSessionFiles(instanceId);
|
||||
}
|
||||
@@ -571,7 +555,7 @@ class WhatsAppConnectionManager {
|
||||
this.io.to(`tenant:${tenantId}`).emit('instance:status', {
|
||||
instanceId,
|
||||
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(() => { });
|
||||
}
|
||||
@@ -693,8 +677,10 @@ class WhatsAppConnectionManager {
|
||||
return;
|
||||
}
|
||||
// Etapa 2: ping já foi enviado, mas ainda sem atividade após GRACE.
|
||||
// Confirma que o socket está zumbi e força reconexão.
|
||||
if (sincePing > WATCHDOG_PING_GRACE_MS) {
|
||||
// InfiniteAPI pode ter ACK mais lento — usa tolerância maior.
|
||||
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);
|
||||
}
|
||||
}, WATCHDOG_INTERVAL_MS);
|
||||
@@ -868,6 +854,7 @@ class WhatsAppConnectionManager {
|
||||
this.sockets.delete(instanceId);
|
||||
this.contactHandlers.delete(instanceId);
|
||||
this.msgHandlers.delete(instanceId);
|
||||
this.engineByInstance.delete(instanceId);
|
||||
logger_1.logger.info({ instanceId }, 'Instancia desconectada manualmente');
|
||||
}
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -962,17 +949,27 @@ class WhatsAppConnectionManager {
|
||||
// ── Resolução LID → phone JID ─────────────────────────────────────
|
||||
// Chats @lid são artefatos do protocolo WhatsApp multi-device:
|
||||
// o JID real do contato é @s.whatsapp.net.
|
||||
// Se já temos o mapeamento (ContactHandler rodou antes), usa o phone JID.
|
||||
// Se não temos resolução, descarta — seria filtrado pelo ChatRepository.
|
||||
// Fontes de resolução (ordem de prioridade):
|
||||
// 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;
|
||||
if (rawJid.endsWith('@lid')) {
|
||||
const phoneJid = lidToPhoneJid.get(rawJid);
|
||||
const phoneJid = lidToPhoneJid.get(rawJid)
|
||||
?? chat.pnJid;
|
||||
if (phoneJid) {
|
||||
jid = phoneJid;
|
||||
// Garante que o contactId mapeado seja usado abaixo
|
||||
const resolvedContactId = lidToContactId.get(rawJid) ?? contactIdByJid.get(jid);
|
||||
if (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 {
|
||||
// Sem resolução disponível: chat será criado via handleHistory quando
|
||||
@@ -982,10 +979,12 @@ class WhatsAppConnectionManager {
|
||||
}
|
||||
}
|
||||
try {
|
||||
// Converte timestamp Unix (segundos) para Date
|
||||
const lastAt = chat.conversationTimestamp
|
||||
? new Date(Number(chat.conversationTimestamp) * 1000)
|
||||
: undefined;
|
||||
// Converte timestamp Unix (segundos) para Date.
|
||||
// Fallback: lastMessageRecvTimestamp (InfiniteAPI define a partir das mensagens do sync)
|
||||
// quando conversationTimestamp é 0 ou null (ex: chats antigos fora da janela de sync).
|
||||
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');
|
||||
// Nome do chat: apenas grupos têm nome (subject) — individuais usam Contact.name
|
||||
const groupName = isGroup ? (chat.name ?? null) : null;
|
||||
@@ -1021,6 +1020,8 @@ class WhatsAppConnectionManager {
|
||||
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)
|
||||
@@ -1102,6 +1103,7 @@ class WhatsAppConnectionManager {
|
||||
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)
|
||||
|
||||
+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" }
|
||||
const lidFromContact = c.lid;
|
||||
try {
|
||||
// Para contatos @lid que possuem campo `jid` (vem do history sync),
|
||||
// usa o JID real como chave primária. Caso contrário, usa c.id como está.
|
||||
// Para contatos @lid que possuem o JID real do telefone:
|
||||
// - 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).
|
||||
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({
|
||||
where: {
|
||||
tenantId_instanceId_jid: {
|
||||
@@ -109,45 +118,46 @@ class ContactHandler {
|
||||
notify: c.notify ?? null,
|
||||
phone: contactJid.endsWith('@lid') ? null : contactJid.split('@')[0],
|
||||
...(imgUrl && imgUrl !== 'changed' ? { avatarUrl: imgUrl } : {}),
|
||||
// Salva mapeamento LID:
|
||||
// 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 } : {}),
|
||||
...(finalLidJid ? { lidJid: finalLidJid } : {}),
|
||||
},
|
||||
update: {
|
||||
name: this.resolveName(c) ?? null,
|
||||
verifiedName: c.verifiedName ?? null,
|
||||
notify: c.notify ?? null,
|
||||
...(imgUrl && imgUrl !== 'changed' ? { avatarUrl: imgUrl } : {}),
|
||||
...(isLid ? { lidJid: c.id } : lidFromContact ? { lidJid: lidFromContact } : {}),
|
||||
...(finalLidJid ? { lidJid: finalLidJid } : {}),
|
||||
},
|
||||
});
|
||||
// Garante vínculo Chat ↔ Contact para chats órfãos
|
||||
// (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({
|
||||
where: {
|
||||
tenantId: this.tenantId,
|
||||
instanceId: this.instanceId,
|
||||
jid: c.id,
|
||||
jid: contactJid,
|
||||
contactId: null,
|
||||
},
|
||||
data: { contactId: contact.id },
|
||||
});
|
||||
// ── Lógica de avatar ──────────────────────────────────────────────
|
||||
// JIDs @lid não suportam profilePictureUrl (retorna 404 no WA)
|
||||
if (!isLid) {
|
||||
// JIDs @lid não suportam profilePictureUrl. Usa contactJid para verificar
|
||||
// pois @lid resolvido para phone JID deve buscar avatar normalmente.
|
||||
const resolvedIsLid = contactJid.endsWith('@lid');
|
||||
if (!resolvedIsLid) {
|
||||
if (imgUrl && imgUrl !== 'changed') {
|
||||
// Avatar veio pronto do Baileys — notifica frontend imediatamente
|
||||
directAvatarCount++;
|
||||
this.io?.to(`tenant:${this.tenantId}`).emit('contact:avatar', {
|
||||
instanceId: this.instanceId,
|
||||
jid: c.id,
|
||||
jid: contactJid,
|
||||
avatarUrl: imgUrl,
|
||||
});
|
||||
}
|
||||
else if (imgUrl === 'changed' || imgUrl === undefined) {
|
||||
// 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 promises_1 = __importDefault(require("fs/promises"));
|
||||
const baileys_1 = require("@whiskeysockets/baileys");
|
||||
const engine_1 = require("../engine");
|
||||
const client_1 = require("@prisma/client");
|
||||
const prisma_1 = require("../../../infra/database/prisma");
|
||||
const logger_1 = require("../../../config/logger");
|
||||
const whatsapp_1 = require("../../../shared/utils/whatsapp");
|
||||
const StorageProvider_1 = require("../../../core/StorageProvider");
|
||||
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) */
|
||||
const MEDIA_DIR = path_1.default.resolve('./media');
|
||||
/**
|
||||
@@ -168,6 +169,72 @@ class MessageHandler {
|
||||
* (será capturada quando o mapeamento LID for descoberto via phoneNumberShare).
|
||||
*/
|
||||
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)
|
||||
return;
|
||||
// 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.
|
||||
// Precisamos converter para @s.whatsapp.net antes de persistir,
|
||||
// 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')) {
|
||||
// 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;
|
||||
if (senderPn) {
|
||||
jidRaw = (0, whatsapp_1.normalizeJid)(senderPn);
|
||||
@@ -195,8 +274,7 @@ class MessageHandler {
|
||||
continue;
|
||||
}
|
||||
else {
|
||||
// Fallback: busca mapeamento LID → phone já salvo no banco
|
||||
// (populado por phoneNumberShare ou processMessage anterior)
|
||||
// 3. DB lookup — mapeamento LID → phone salvo pelo ContactHandler
|
||||
const contact = await prisma_1.prisma.contact.findFirst({
|
||||
where: { tenantId: this.tenantId, instanceId: this.instanceId, lidJid: jidRaw },
|
||||
select: { jid: true },
|
||||
@@ -205,12 +283,12 @@ class MessageHandler {
|
||||
jidRaw = contact.jid;
|
||||
}
|
||||
else {
|
||||
// Sem resolução possível, ou contato ainda armazenado com JID @lid
|
||||
// (o mapeamento phone ainda não chegou via phoneNumberShare / senderPn).
|
||||
// Sem resolução possível — descarta (será capturada via phoneNumberShare futuro)
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const jid = (0, whatsapp_1.normalizeJid)(jidRaw);
|
||||
const list = byJid.get(jid) ?? [];
|
||||
list.push(msg);
|
||||
@@ -256,7 +334,7 @@ class MessageHandler {
|
||||
if (!message || !key.id)
|
||||
return [];
|
||||
message = unwrapInnerMessage(message);
|
||||
const contentType = (0, baileys_1.getContentType)(message);
|
||||
const contentType = (0, engine_1.getContentType)(message);
|
||||
if (!contentType)
|
||||
return [];
|
||||
// Descarta tipos de protocolo/sistema que não representam conteúdo real
|
||||
@@ -301,7 +379,7 @@ class MessageHandler {
|
||||
}
|
||||
}
|
||||
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;
|
||||
key.remoteJid = phoneJid;
|
||||
}
|
||||
const contentType = (0, baileys_1.getContentType)(message);
|
||||
const contentType = (0, engine_1.getContentType)(message);
|
||||
if (!contentType)
|
||||
return;
|
||||
// Descarta tipos de protocolo/sistema (sem conteúdo real para o usuário)
|
||||
@@ -659,6 +737,8 @@ class MessageHandler {
|
||||
: null,
|
||||
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) {
|
||||
logger_1.logger.error({ err, chatId }, '[MessageHandler] Falha ao emitir chat:upsert');
|
||||
@@ -678,7 +758,7 @@ class MessageHandler {
|
||||
*/
|
||||
async downloadAndPersistMedia(msg, savedMsgId, chatId, contentType) {
|
||||
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)
|
||||
return;
|
||||
// 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,
|
||||
})
|
||||
|
||||
// Persiste sticker na biblioteca (deduplicado por sha256)
|
||||
if (contentType === 'stickerMessage') {
|
||||
this.persistSticker(msg, mediaUrl).catch(() => {})
|
||||
}
|
||||
|
||||
logger.debug({ savedMsgId, filePath }, 'Mídia salva')
|
||||
} catch (err) {
|
||||
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
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -31,6 +31,8 @@ import { adminRouter } from './modules/auth/admin.routes'
|
||||
import { pairRouter } from './modules/pair/pair.routes'
|
||||
import { buildTemplateRoutes } from './modules/templates/template.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 { authMiddleware } from './shared/middlewares/auth.middleware'
|
||||
import { adminMiddleware } from './shared/middlewares/admin.middleware'
|
||||
@@ -87,6 +89,30 @@ async function bootstrap() {
|
||||
const scheduler = new SchedulerService(whatsAppManager)
|
||||
app.use('/api/templates', authMiddleware, buildTemplateRoutes())
|
||||
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
|
||||
app.get('/api/plan/status', authMiddleware, async (req, res) => {
|
||||
|
||||
@@ -13,14 +13,18 @@ import {
|
||||
LayoutTemplate,
|
||||
BookOpen,
|
||||
Plus,
|
||||
Sticker,
|
||||
} from 'lucide-react';
|
||||
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 RichMessageComposer from './RichMessageComposer';
|
||||
import TemplateLibrary from './TemplateLibrary';
|
||||
import ScheduleMessageModal from './ScheduleMessageModal';
|
||||
import FilePreviewModal from './FilePreviewModal';
|
||||
import StickerPanel from './StickerPanel';
|
||||
import { mediaApi } from '../services/chatApiService';
|
||||
|
||||
interface Message {
|
||||
id: number;
|
||||
@@ -64,7 +68,9 @@ const InputBar: React.FC<InputBarProps> = ({
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
|
||||
const [showStickerPanel, setShowStickerPanel] = useState(false);
|
||||
const [showAttachMenu, setShowAttachMenu] = useState(false);
|
||||
const stickerPanelRef = useRef<HTMLDivElement>(null);
|
||||
const [previewFile, setPreviewFile] = useState<File | null>(null);
|
||||
const attachMenuRef = useRef<HTMLDivElement>(null);
|
||||
const [showRichComposer, setShowRichComposer] = useState(false);
|
||||
@@ -181,12 +187,15 @@ const InputBar: React.FC<InputBarProps> = ({
|
||||
}
|
||||
}, [newMessage]);
|
||||
|
||||
// Close emoji picker and attach menu on click outside
|
||||
// Close emoji picker, sticker panel and attach menu on click outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (emojiPickerRef.current && !emojiPickerRef.current.contains(event.target as Node)) {
|
||||
setShowEmojiPicker(false);
|
||||
}
|
||||
if (stickerPanelRef.current && !stickerPanelRef.current.contains(event.target as Node)) {
|
||||
setShowStickerPanel(false);
|
||||
}
|
||||
if (attachMenuRef.current && !attachMenuRef.current.contains(event.target as Node)) {
|
||||
setShowAttachMenu(false);
|
||||
}
|
||||
@@ -195,6 +204,12 @@ const InputBar: React.FC<InputBarProps> = ({
|
||||
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) => {
|
||||
// Mention dropdown navigation
|
||||
if (mentionQuery !== null && filteredParticipants.length > 0) {
|
||||
@@ -226,9 +241,8 @@ const InputBar: React.FC<InputBarProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const onEmojiClick = (emojiData: any) => {
|
||||
onNewMessageChange(newMessage + emojiData.emoji);
|
||||
// Do not close picker, common UX allows multiple emojis
|
||||
const onEmojiSelect = (emoji: any) => {
|
||||
onNewMessageChange(newMessage + (emoji.native ?? ''));
|
||||
};
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -356,12 +370,36 @@ const InputBar: React.FC<InputBarProps> = ({
|
||||
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"
|
||||
>
|
||||
<EmojiPicker
|
||||
onEmojiClick={onEmojiClick}
|
||||
autoFocusSearch={false}
|
||||
theme={Theme.LIGHT}
|
||||
width={320}
|
||||
height={400}
|
||||
<Picker
|
||||
data={emojiData}
|
||||
onEmojiSelect={onEmojiSelect}
|
||||
locale="pt"
|
||||
theme="light"
|
||||
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>
|
||||
)}
|
||||
@@ -482,12 +520,23 @@ const InputBar: React.FC<InputBarProps> = ({
|
||||
|
||||
{/* Emoji — mantém no lugar */}
|
||||
<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]'}`}
|
||||
title="Emojis"
|
||||
>
|
||||
<Smile className="w-[26px] h-[26px]" />
|
||||
</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 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",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@emoji-mart/data": "^1.2.1",
|
||||
"@emoji-mart/react": "^1.1.1",
|
||||
"@xyflow/react": "^12.10.2",
|
||||
"axios": "^1.6.0",
|
||||
"date-fns": "^4.1.0",
|
||||
@@ -46,6 +48,20 @@
|
||||
"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": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
@@ -1354,6 +1370,12 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "4.18.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-picker-react/-/emoji-picker-react-4.18.0.tgz",
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emoji-mart/data": "^1.2.1",
|
||||
"@emoji-mart/react": "^1.1.1",
|
||||
"@xyflow/react": "^12.10.2",
|
||||
"axios": "^1.6.0",
|
||||
"date-fns": "^4.1.0",
|
||||
|
||||
@@ -127,15 +127,26 @@ export default function WhatsAppInboxPage() {
|
||||
const [isMobile, setIsMobile] = useState(false)
|
||||
const [isNewConvoModalOpen, setIsNewConvoModalOpen] = useState(false)
|
||||
|
||||
// Preloader: começa visível e some após o primeiro ciclo loading true→false
|
||||
// O delay de 400ms dá tempo ao browser iniciar o carregamento dos avatares
|
||||
// antes do overlay sumir, evitando o flash de skeletons pulsando.
|
||||
const [pageReady, setPageReady] = useState(false)
|
||||
// Preloader: aparece APENAS em hard reload (F5 / URL direta) — nunca em
|
||||
// navegação client-side (router.push). Detectado por:
|
||||
// 1. performance.getEntriesByType('navigation')[0].type === 'reload'
|
||||
// 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 readyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
useEffect(() => {
|
||||
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
|
||||
return () => { if (readyTimerRef.current) clearTimeout(readyTimerRef.current) }
|
||||
|
||||
@@ -202,6 +202,17 @@ export const mediaApi = {
|
||||
})
|
||||
.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 ─────────────────────────────────────────────────────────────
|
||||
@@ -517,3 +528,26 @@ export const scheduleApi = {
|
||||
logs: (id: string) =>
|
||||
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 fileName = `${Date.now()}-${safeBase}`;
|
||||
// Path no bucket: whatsapp/{tenantId}/{instanceId}/{file} ou {category}/{file}
|
||||
const relativePath = (category === 'whatsapp' && payload.usuarioSis && payload.sessionJID)
|
||||
// Path no bucket: customPath (quando fornecido) > whatsapp/{t}/{i}/{file} > {cat}/{file}
|
||||
const relativePath = payload.customPath
|
||||
?? ((category === 'whatsapp' && payload.usuarioSis && payload.sessionJID)
|
||||
? `whatsapp/${payload.usuarioSis}/${payload.sessionJID}/${fileName}`
|
||||
: `${category}/${fileName}`;
|
||||
: `${category}/${fileName}`);
|
||||
if (!this.s3 || !this.bucket) {
|
||||
return this.saveToLocalFallback(relativePath, buffer, originalName, category);
|
||||
}
|
||||
@@ -317,6 +318,26 @@ class UnifiedStorageProviderPlugin {
|
||||
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 */
|
||||
saveToLocalFallback(relativePath, buffer, originalName, _category) {
|
||||
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