Compare commits

..

No commits in common. "main" and "2.3.5" have entirely different histories.
main ... 2.3.5

47 changed files with 2703 additions and 4764 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@ -59,7 +59,7 @@ body:
value: | value: |
- OS: [e.g. Ubuntu 20.04, Windows 10, macOS 12.0] - OS: [e.g. Ubuntu 20.04, Windows 10, macOS 12.0]
- Node.js version: [e.g. 18.17.0] - Node.js version: [e.g. 18.17.0]
- Evolution API version: [e.g. 2.3.7] - Evolution API version: [e.g. 2.3.5]
- Database: [e.g. PostgreSQL 14, MySQL 8.0] - Database: [e.g. PostgreSQL 14, MySQL 8.0]
- Connection type: [e.g. Baileys, WhatsApp Business API] - Connection type: [e.g. Baileys, WhatsApp Business API]
validations: validations:

View File

@ -1,182 +1,3 @@
# 2.3.7 (2025-12-05)
### Features
* **WhatsApp Business Meta Templates**: Add update and delete endpoints for Meta templates
- New endpoints to edit and delete WhatsApp Business templates
- Added DTOs and validation schemas for template management
- Enhanced template lifecycle management capabilities
* **Events API**: Add isLatest and progress to messages.set event
- Allows consumers to know when history sync is complete (isLatest=true)
- Track sync progress percentage through webhooks
- Added extra field to EmitData type for additional payload properties
- Updated all event controllers (webhook, rabbitmq, sqs, websocket, pusher, kafka, nats)
* **N8N Integration**: Add quotedMessage to payload in sendMessageToBot
- Support for quoted messages in N8N chatbot integration
- Enhanced message context information
* **WebSocket**: Add wildcard "*" to allow all hosts to connect via websocket
- More flexible host configuration for WebSocket connections
- Improved host validation logic in WebsocketController
* **Pix Support**: Handle interactive button message for pix
- Support for interactive Pix button messages
- Enhanced payment flow integration
### Fixed
* **Baileys Message Processor**: Fix incoming message events not working after reconnection
- Added cleanup logic in mount() to prevent memory leaks from multiple subscriptions
- Recreate messageSubject if it was completed during logout
- Remount messageProcessor in connectToWhatsapp() to ensure subscription is active
- Fixed issue where onDestroy() calls complete() on RxJS Subject, making it permanently closed
- Ensures old subscriptions are properly cleaned up before creating new ones
* **Baileys Authentication**: Resolve "waiting for message" state after reconnection
- Fixed Redis keys not being properly removed during instance logout
- Prevented loading of old/invalid cryptographic keys on reconnection
- Fixed blocking state where instances authenticate but cannot send messages
- Ensures new credentials (creds) are properly used after reconnection
* **OnWhatsapp Cache**: Prevent unique constraint errors and optimize database writes
- Fixed `Unique constraint failed on the fields: (remoteJid)` error when sending to groups
- Refactored query to use OR condition finding by jidOptions or remoteJid
- Added deep comparison to skip unnecessary database updates
- Replaced sequential processing with Promise.allSettled for parallel execution
- Sorted JIDs alphabetically in jidOptions for accurate change detection
- Added normalizeJid helper function for cleaner code
* **Proxy Integration**: Fix "Media upload failed on all hosts" error when using proxy
- Created makeProxyAgentUndici() for Undici-compatible proxy agents
- Fixed compatibility with Node.js 18+ native fetch() implementation
- Replaced traditional HttpsProxyAgent/SocksProxyAgent with Undici ProxyAgent
- Maintained legacy makeProxyAgent() for Axios compatibility
- Fixed protocol handling in makeProxyAgent to prevent undefined errors
* **WhatsApp Business API**: Fix base64, filename and caption handling
- Corrected base64 media conversion in Business API
- Fixed filename handling for document messages
- Improved caption processing for media messages
- Enhanced remoteJid validation and processing
* **Chat Service**: Fix fetchChats and message panel errors
- Fixed cleanMessageData errors in Manager message panel
- Improved chat fetching reliability
- Enhanced message data sanitization
* **Contact Filtering**: Apply where filters correctly in findContacts endpoint
- Fixed endpoint to process all where clause fields (id, remoteJid, pushName)
- Previously only processed remoteJid field, ignoring other filters
- Added remoteJid field to contactValidateSchema for proper validation
- Maintained multi-tenant isolation with instanceId filtering
- Allows filtering contacts by any supported field instead of returning all contacts
* **Chatwoot and Baileys Integration**: Multiple integration improvements
- Enhanced code formatting and consistency
- Fixed integration issues between Chatwoot and Baileys services
- Improved message handling and delivery
* **Baileys Message Loss**: Prevent message loss from WhatsApp stub placeholders
- Fixed messages being lost and not saved to database, especially for channels/newsletters (@lid)
- Detects WhatsApp stubs through messageStubParameters containing 'Message absent from node'
- Prevents adding stubs to duplicate message cache
- Allows real message to be processed when it arrives after decryption
- Maintains stub discard to avoid saving empty placeholders
* **Database Contacts**: Respect DATABASE_SAVE_DATA_CONTACTS in contact updates
- Added missing conditional checks for DATABASE_SAVE_DATA_CONTACTS configuration
- Fixed profile picture updates attempting to save when database save is disabled
- Fixed unawaited promise in contacts.upsert handler
* **Prisma/PostgreSQL**: Add unique constraint to Chat model
- Generated migration to add unique index on instanceId and remoteJid
- Added deduplication step before creating index to prevent constraint violations
- Prevents chat duplication in database
* **MinIO Upload**: Handle messageContextInfo in media upload to prevent MinIO errors
- Prevents errors when uploading media with messageContextInfo metadata
- Improved error handling for media storage operations
* **Typebot**: Fix message routing for @lid JIDs
- Typebot now responds to messages from JIDs ending with @lid
- Maintains complete JID for @lid instead of extracting only number
- Fixed condition: `remoteJid.includes('@lid') ? remoteJid : remoteJid.split('@')[0]`
- Handles both @s.whatsapp.net and @lid message formats
* **Message Filtering**: Unify remoteJid filtering using OR with remoteJidAlt
- Improved message filtering with alternative JID support
- Better handling of messages with different JID formats
* **@lid Integration**: Multiple fixes for @lid problems, message events and chatwoot errors
- Reorganized imports and improved message handling in BaileysStartupService
- Enhanced remoteJid processing to handle @lid cases
- Improved jid normalization and type safety in Chatwoot integration
- Streamlined message handling logic and cache management
- Refactored message handling and polling updates with decryption logic for poll votes
- Improved event processing flow for various message types
* **Chatwoot Contacts**: Fix contact duplication error on import
- Resolved 'ON CONFLICT DO UPDATE command cannot affect row a second time' error
- Removed attempt to update identifier field in conflict (part of constraint)
- Changed to update only updated_at field: `updated_at = NOW()`
- Allows duplicate contacts to be updated correctly without errors
* **Chatwoot Service**: Fix async handling in update_last_seen method
- Added missing await for chatwootRequest in read message processing
- Prevents service failure when processing read messages
* **Metrics Access**: Fix IP validation including x-forwarded-for
- Uses all IPs including x-forwarded-for header when checking metrics access
- Improved security and access control for metrics endpoint
### Dependencies
* **Baileys**: Updated to version 7.0.0-rc.9
- Latest release candidate with multiple improvements and bug fixes
* **AWS SDK**: Updated packages to version 3.936.0
- Enhanced functionality and compatibility
- Performance improvements
### Code Quality & Refactoring
* **Template Management**: Remove unused template edit/delete DTOs after refactoring
* **Proxy Utilities**: Improve makeProxyAgent for Undici compatibility
* **Code Formatting**: Enhance code formatting and consistency across services
* **BaileysStartupService**: Fix indentation and remove unnecessary blank lines
* **Event Controllers**: Guard extra spread and prevent core field override in all event controllers
* **Import Organization**: Reorganize imports for better code structure and maintainability
# 2.3.6 (2025-10-21)
### Features
* **Baileys, Chatwoot, OnWhatsapp Cache**: Multiple implementations and fixes
- Fixed cache for PN, LID and g.us numbers to send correct number
- Fixed audio and document sending via Chatwoot in Baileys channel
- Multiple fixes in Chatwoot integration
- Fixed ignored messages when receiving leads
### Fixed
* **Baileys**: Fix buffer storage in database
- Correctly save Uint8Array values to database
* **Baileys**: Simplify logging of messageSent object
- Fixed "this.isZero not is function" error
### Chore
* **Version**: Bump version to 2.3.6 and update Baileys dependency to 7.0.0-rc.6
* **Workflows**: Update checkout step to include submodules
- Added 'submodules: recursive' option to checkout step in multiple workflow files to ensure submodules are properly initialized during CI/CD processes
* **Manager**: Update asset files and install process
- Updated subproject reference in evolution-manager-v2 to the latest commit
- Enhanced the manager_install.sh script to include npm install and build steps
- Replaced old JavaScript asset file with a new version for improved performance
- Added a new CSS file for consistent styling across the application
# 2.3.5 (2025-10-15) # 2.3.5 (2025-10-15)
### Features ### Features

View File

@ -2,7 +2,7 @@ version: "3.7"
services: services:
evolution_v2: evolution_v2:
image: evoapicloud/evolution-api:v2.3.7 image: evoapicloud/evolution-api:v2.3.5
volumes: volumes:
- evolution_instances:/evolution/instances - evolution_instances:/evolution/instances
networks: networks:

4935
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{ {
"name": "evolution-api", "name": "evolution-api",
"version": "2.3.7", "version": "2.3.5",
"description": "Rest api for communication with WhatsApp", "description": "Rest api for communication with WhatsApp",
"main": "./dist/main.js", "main": "./dist/main.js",
"type": "commonjs", "type": "commonjs",
@ -77,7 +77,7 @@
"amqplib": "^0.10.5", "amqplib": "^0.10.5",
"audio-decode": "^2.2.3", "audio-decode": "^2.2.3",
"axios": "^1.7.9", "axios": "^1.7.9",
"baileys": "7.0.0-rc.9", "baileys": "^7.0.0-rc.5",
"class-validator": "^0.14.1", "class-validator": "^0.14.1",
"compression": "^1.7.5", "compression": "^1.7.5",
"cors": "^2.8.5", "cors": "^2.8.5",
@ -90,14 +90,12 @@
"fluent-ffmpeg": "^2.1.3", "fluent-ffmpeg": "^2.1.3",
"form-data": "^4.0.1", "form-data": "^4.0.1",
"https-proxy-agent": "^7.0.6", "https-proxy-agent": "^7.0.6",
"fetch-socks": "^1.3.2",
"i18next": "^23.7.19", "i18next": "^23.7.19",
"jimp": "^1.6.0", "jimp": "^1.6.0",
"json-schema": "^0.4.0", "json-schema": "^0.4.0",
"jsonschema": "^1.4.1", "jsonschema": "^1.4.1",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"kafkajs": "^2.2.4", "kafkajs": "^2.2.4",
"libphonenumber-js": "^1.12.25",
"link-preview-js": "^3.0.13", "link-preview-js": "^3.0.13",
"long": "^5.2.3", "long": "^5.2.3",
"mediainfo.js": "^0.3.4", "mediainfo.js": "^0.3.4",
@ -123,7 +121,6 @@
"socks-proxy-agent": "^8.0.5", "socks-proxy-agent": "^8.0.5",
"swagger-ui-express": "^5.0.1", "swagger-ui-express": "^5.0.1",
"tsup": "^8.3.5", "tsup": "^8.3.5",
"undici": "^7.16.0",
"uuid": "^13.0.0" "uuid": "^13.0.0"
}, },
"devDependencies": { "devDependencies": {

View File

@ -1,16 +0,0 @@
-- 1. Cleanup: Remove duplicate chats, keeping the most recently updated one
DELETE FROM "Chat"
WHERE id IN (
SELECT id FROM (
SELECT id,
ROW_NUMBER() OVER (
PARTITION BY "instanceId", "remoteJid"
ORDER BY "updatedAt" DESC
) as row_num
FROM "Chat"
) t
WHERE t.row_num > 1
);
-- 2. Create the unique index (Constraint)
CREATE UNIQUE INDEX "Chat_instanceId_remoteJid_key" ON "Chat"("instanceId", "remoteJid");

View File

@ -132,7 +132,6 @@ model Chat {
instanceId String instanceId String
unreadMessages Int @default(0) unreadMessages Int @default(0)
@@unique([instanceId, remoteJid])
@@index([instanceId]) @@index([instanceId])
@@index([remoteJid]) @@index([remoteJid])
} }

View File

@ -92,15 +92,6 @@ export class InstanceController {
instanceId: instanceId, instanceId: instanceId,
}); });
const instanceDto: InstanceDto = {
instanceName: instance.instanceName,
instanceId: instance.instanceId,
connectionStatus:
typeof instance.connectionStatus === 'string'
? instance.connectionStatus
: instance.connectionStatus?.state || 'unknown',
};
if (instanceData.proxyHost && instanceData.proxyPort && instanceData.proxyProtocol) { if (instanceData.proxyHost && instanceData.proxyPort && instanceData.proxyProtocol) {
const testProxy = await this.proxyService.testProxy({ const testProxy = await this.proxyService.testProxy({
host: instanceData.proxyHost, host: instanceData.proxyHost,
@ -112,7 +103,8 @@ export class InstanceController {
if (!testProxy) { if (!testProxy) {
throw new BadRequestException('Invalid proxy'); throw new BadRequestException('Invalid proxy');
} }
await this.proxyService.createProxy(instanceDto, {
await this.proxyService.createProxy(instance, {
enabled: true, enabled: true,
host: instanceData.proxyHost, host: instanceData.proxyHost,
port: instanceData.proxyPort, port: instanceData.proxyPort,
@ -133,7 +125,7 @@ export class InstanceController {
wavoipToken: instanceData.wavoipToken || '', wavoipToken: instanceData.wavoipToken || '',
}; };
await this.settingsService.create(instanceDto, settings); await this.settingsService.create(instance, settings);
let webhookWaBusiness = null, let webhookWaBusiness = null,
accessTokenWaBusiness = ''; accessTokenWaBusiness = '';
@ -163,10 +155,7 @@ export class InstanceController {
integration: instanceData.integration, integration: instanceData.integration,
webhookWaBusiness, webhookWaBusiness,
accessTokenWaBusiness, accessTokenWaBusiness,
status: status: instance.connectionStatus.state,
typeof instance.connectionStatus === 'string'
? instance.connectionStatus
: instance.connectionStatus?.state || 'unknown',
}, },
hash, hash,
webhook: { webhook: {
@ -228,7 +217,7 @@ export class InstanceController {
const urlServer = this.configService.get<HttpServer>('SERVER').URL; const urlServer = this.configService.get<HttpServer>('SERVER').URL;
try { try {
this.chatwootService.create(instanceDto, { this.chatwootService.create(instance, {
enabled: true, enabled: true,
accountId: instanceData.chatwootAccountId, accountId: instanceData.chatwootAccountId,
token: instanceData.chatwootToken, token: instanceData.chatwootToken,
@ -257,10 +246,7 @@ export class InstanceController {
integration: instanceData.integration, integration: instanceData.integration,
webhookWaBusiness, webhookWaBusiness,
accessTokenWaBusiness, accessTokenWaBusiness,
status: status: instance.connectionStatus.state,
typeof instance.connectionStatus === 'string'
? instance.connectionStatus
: instance.connectionStatus?.state || 'unknown',
}, },
hash, hash,
webhook: { webhook: {
@ -352,38 +338,20 @@ export class InstanceController {
throw new BadRequestException('The "' + instanceName + '" instance does not exist'); throw new BadRequestException('The "' + instanceName + '" instance does not exist');
} }
if (state === 'close') { if (state == 'close') {
throw new BadRequestException('The "' + instanceName + '" instance is not connected'); throw new BadRequestException('The "' + instanceName + '" instance is not connected');
} } else if (state == 'open') {
this.logger.info(`Restarting instance: ${instanceName}`);
if (typeof instance.restart === 'function') {
await instance.restart();
// Wait a bit for the reconnection to be established
await new Promise((r) => setTimeout(r, 2000));
return {
instance: {
instanceName: instanceName,
status: instance.connectionStatus?.state || 'connecting',
},
};
}
// Fallback for Baileys (uses different mechanism)
if (state === 'open' || state === 'connecting') {
if (this.configService.get<Chatwoot>('CHATWOOT').ENABLED) instance.clearCacheChatwoot(); if (this.configService.get<Chatwoot>('CHATWOOT').ENABLED) instance.clearCacheChatwoot();
this.logger.info('restarting instance' + instanceName);
instance.client?.ws?.close();
instance.client?.end(new Error('restart'));
return await this.connectToWhatsapp({ instanceName });
} else if (state == 'connecting') {
instance.client?.ws?.close(); instance.client?.ws?.close();
instance.client?.end(new Error('restart')); instance.client?.end(new Error('restart'));
return await this.connectToWhatsapp({ instanceName }); return await this.connectToWhatsapp({ instanceName });
} }
return {
instance: {
instanceName: instanceName,
status: state,
},
};
} catch (error) { } catch (error) {
this.logger.error(error); this.logger.error(error);
return { error: true, message: error.toString() }; return { error: true, message: error.toString() };
@ -441,7 +409,7 @@ export class InstanceController {
} }
try { try {
await this.waMonitor.waInstances[instanceName]?.logoutInstance(); this.waMonitor.waInstances[instanceName]?.logoutInstance();
return { status: 'SUCCESS', error: false, response: { message: 'Instance logged out' } }; return { status: 'SUCCESS', error: false, response: { message: 'Instance logged out' } };
} catch (error) { } catch (error) {

View File

@ -12,15 +12,4 @@ export class TemplateController {
public async findTemplate(instance: InstanceDto) { public async findTemplate(instance: InstanceDto) {
return this.templateService.find(instance); return this.templateService.find(instance);
} }
public async editTemplate(
instance: InstanceDto,
data: { templateId: string; category?: string; components?: any; allowCategoryChange?: boolean; ttl?: number },
) {
return this.templateService.edit(instance, data);
}
public async deleteTemplate(instance: InstanceDto, data: { name: string; hsmId?: string }) {
return this.templateService.delete(instance, data);
}
} }

View File

@ -12,7 +12,6 @@ export class InstanceDto extends IntegrationDto {
token?: string; token?: string;
status?: string; status?: string;
ownerJid?: string; ownerJid?: string;
connectionStatus?: string;
profileName?: string; profileName?: string;
profilePicUrl?: string; profilePicUrl?: string;
// settings // settings

View File

@ -6,16 +6,3 @@ export class TemplateDto {
components: any; components: any;
webhookUrl?: string; webhookUrl?: string;
} }
export class TemplateEditDto {
templateId: string;
category?: 'AUTHENTICATION' | 'MARKETING' | 'UTILITY';
allowCategoryChange?: boolean;
ttl?: number;
components?: any;
}
export class TemplateDeleteDto {
name: string;
hsmId?: string;
}

View File

@ -516,9 +516,7 @@ export class BusinessStartupService extends ChannelStartupService {
const mediaUrl = await s3Service.getObjectUrl(fullName); const mediaUrl = await s3Service.getObjectUrl(fullName);
messageRaw.message.mediaUrl = mediaUrl; messageRaw.message.mediaUrl = mediaUrl;
if (this.localWebhook.enabled && this.localWebhook.webhookBase64) {
messageRaw.message.base64 = buffer.data.toString('base64'); messageRaw.message.base64 = buffer.data.toString('base64');
}
// Processar OpenAI speech-to-text para áudio após o mediaUrl estar disponível // Processar OpenAI speech-to-text para áudio após o mediaUrl estar disponível
if (this.configService.get<Openai>('OPENAI').ENABLED && mediaType === 'audio') { if (this.configService.get<Openai>('OPENAI').ENABLED && mediaType === 'audio') {
@ -556,19 +554,11 @@ export class BusinessStartupService extends ChannelStartupService {
this.logger.error(['Error on upload file to minio', error?.message, error?.stack]); this.logger.error(['Error on upload file to minio', error?.message, error?.stack]);
} }
} else { } else {
if (this.localWebhook.enabled && this.localWebhook.webhookBase64) {
const buffer = await this.downloadMediaMessage(received?.messages[0]); const buffer = await this.downloadMediaMessage(received?.messages[0]);
messageRaw.message.base64 = buffer.toString('base64'); messageRaw.message.base64 = buffer.toString('base64');
}
// Processar OpenAI speech-to-text para áudio mesmo sem S3 // Processar OpenAI speech-to-text para áudio mesmo sem S3
if (this.configService.get<Openai>('OPENAI').ENABLED && message.type === 'audio') { if (this.configService.get<Openai>('OPENAI').ENABLED && message.type === 'audio') {
let openAiBase64 = messageRaw.message.base64;
if (!openAiBase64) {
const buffer = await this.downloadMediaMessage(received?.messages[0]);
openAiBase64 = buffer.toString('base64');
}
const openAiDefaultSettings = await this.prismaRepository.openaiSetting.findFirst({ const openAiDefaultSettings = await this.prismaRepository.openaiSetting.findFirst({
where: { where: {
instanceId: this.instanceId, instanceId: this.instanceId,
@ -584,7 +574,7 @@ export class BusinessStartupService extends ChannelStartupService {
openAiDefaultSettings.OpenaiCreds, openAiDefaultSettings.OpenaiCreds,
{ {
message: { message: {
base64: openAiBase64, base64: messageRaw.message.base64,
...messageRaw, ...messageRaw,
}, },
}, },
@ -1026,7 +1016,6 @@ export class BusinessStartupService extends ChannelStartupService {
[message['mediaType']]: { [message['mediaType']]: {
[message['type']]: message['id'], [message['type']]: message['id'],
...(message['mediaType'] !== 'audio' && ...(message['mediaType'] !== 'audio' &&
message['mediaType'] !== 'video' &&
message['fileName'] && message['fileName'] &&
!isImage && { filename: message['fileName'] }), !isImage && { filename: message['fileName'] }),
...(message['mediaType'] !== 'audio' && message['caption'] && { caption: message['caption'] }), ...(message['mediaType'] !== 'audio' && message['caption'] && { caption: message['caption'] }),
@ -1617,14 +1606,9 @@ export class BusinessStartupService extends ChannelStartupService {
const messageType = msg.messageType.includes('Message') ? msg.messageType : msg.messageType + 'Message'; const messageType = msg.messageType.includes('Message') ? msg.messageType : msg.messageType + 'Message';
const mediaMessage = msg.message[messageType]; const mediaMessage = msg.message[messageType];
if (!msg.message?.base64) {
const buffer = await this.downloadMediaMessage({ type: messageType, ...msg.message });
msg.message.base64 = buffer.toString('base64');
}
return { return {
mediaType: msg.messageType, mediaType: msg.messageType,
fileName: mediaMessage?.fileName || mediaMessage?.filename, fileName: mediaMessage?.fileName,
caption: mediaMessage?.caption, caption: mediaMessage?.caption,
size: { size: {
fileLength: mediaMessage?.fileLength, fileLength: mediaMessage?.fileLength,

View File

@ -1,5 +1,5 @@
import { Logger } from '@config/logger.config'; import { Logger } from '@config/logger.config';
import { BaileysEventMap, MessageUpsertType, WAMessage } from 'baileys'; import { BaileysEventMap, MessageUpsertType, proto } from 'baileys';
import { catchError, concatMap, delay, EMPTY, from, retryWhen, Subject, Subscription, take, tap } from 'rxjs'; import { catchError, concatMap, delay, EMPTY, from, retryWhen, Subject, Subscription, take, tap } from 'rxjs';
type MessageUpsertPayload = BaileysEventMap['messages.upsert']; type MessageUpsertPayload = BaileysEventMap['messages.upsert'];
@ -12,29 +12,13 @@ export class BaileysMessageProcessor {
private subscription?: Subscription; private subscription?: Subscription;
protected messageSubject = new Subject<{ protected messageSubject = new Subject<{
messages: WAMessage[]; messages: proto.IWebMessageInfo[];
type: MessageUpsertType; type: MessageUpsertType;
requestId?: string; requestId?: string;
settings: any; settings: any;
}>(); }>();
mount({ onMessageReceive }: MountProps) { mount({ onMessageReceive }: MountProps) {
// Se já existe subscription, fazer cleanup primeiro
if (this.subscription && !this.subscription.closed) {
this.subscription.unsubscribe();
}
// Se o Subject foi completado, recriar
if (this.messageSubject.closed) {
this.processorLogs.warn('MessageSubject was closed, recreating...');
this.messageSubject = new Subject<{
messages: WAMessage[];
type: MessageUpsertType;
requestId?: string;
settings: any;
}>();
}
this.subscription = this.messageSubject this.subscription = this.messageSubject
.pipe( .pipe(
tap(({ messages }) => { tap(({ messages }) => {

View File

@ -82,7 +82,7 @@ import { createId as cuid } from '@paralleldrive/cuid2';
import { Instance, Message } from '@prisma/client'; import { Instance, Message } from '@prisma/client';
import { createJid } from '@utils/createJid'; import { createJid } from '@utils/createJid';
import { fetchLatestWaWebVersion } from '@utils/fetchLatestWaWebVersion'; import { fetchLatestWaWebVersion } from '@utils/fetchLatestWaWebVersion';
import { makeProxyAgent, makeProxyAgentUndici } from '@utils/makeProxyAgent'; import { makeProxyAgent } from '@utils/makeProxyAgent';
import { getOnWhatsappCache, saveOnWhatsappCache } from '@utils/onWhatsappCache'; import { getOnWhatsappCache, saveOnWhatsappCache } from '@utils/onWhatsappCache';
import { status } from '@utils/renderStatus'; import { status } from '@utils/renderStatus';
import { sendTelemetry } from '@utils/sendTelemetry'; import { sendTelemetry } from '@utils/sendTelemetry';
@ -99,7 +99,6 @@ import makeWASocket, {
Chat, Chat,
ConnectionState, ConnectionState,
Contact, Contact,
decryptPollVote,
delay, delay,
DisconnectReason, DisconnectReason,
downloadContentFromMessage, downloadContentFromMessage,
@ -114,7 +113,6 @@ import makeWASocket, {
isJidGroup, isJidGroup,
isJidNewsletter, isJidNewsletter,
isPnUser, isPnUser,
jidNormalizedUser,
makeCacheableSignalKeyStore, makeCacheableSignalKeyStore,
MessageUpsertType, MessageUpsertType,
MessageUserReceiptUpdate, MessageUserReceiptUpdate,
@ -135,7 +133,7 @@ import { Label } from 'baileys/lib/Types/Label';
import { LabelAssociation } from 'baileys/lib/Types/LabelAssociation'; import { LabelAssociation } from 'baileys/lib/Types/LabelAssociation';
import { spawn } from 'child_process'; import { spawn } from 'child_process';
import { isArray, isBase64, isURL } from 'class-validator'; import { isArray, isBase64, isURL } from 'class-validator';
import { createHash } from 'crypto'; import { randomBytes } from 'crypto';
import EventEmitter2 from 'eventemitter2'; import EventEmitter2 from 'eventemitter2';
import ffmpeg from 'fluent-ffmpeg'; import ffmpeg from 'fluent-ffmpeg';
import FormData from 'form-data'; import FormData from 'form-data';
@ -250,7 +248,6 @@ export class BaileysStartupService extends ChannelStartupService {
private readonly userDevicesCache: CacheStore = new NodeCache({ stdTTL: 300000, useClones: false }); private readonly userDevicesCache: CacheStore = new NodeCache({ stdTTL: 300000, useClones: false });
private endSession = false; private endSession = false;
private logBaileys = this.configService.get<Log>('LOG').BAILEYS; private logBaileys = this.configService.get<Log>('LOG').BAILEYS;
private eventProcessingQueue: Promise<void> = Promise.resolve();
// Cache TTL constants (in seconds) // Cache TTL constants (in seconds)
private readonly MESSAGE_CACHE_TTL_SECONDS = 5 * 60; // 5 minutes - avoid duplicate message processing private readonly MESSAGE_CACHE_TTL_SECONDS = 5 * 60; // 5 minutes - avoid duplicate message processing
@ -270,28 +267,6 @@ export class BaileysStartupService extends ChannelStartupService {
this.client?.ws?.close(); this.client?.ws?.close();
const db = this.configService.get<Database>('DATABASE');
const cache = this.configService.get<CacheConf>('CACHE');
const provider = this.configService.get<ProviderSession>('PROVIDER');
if (provider?.ENABLED) {
const authState = await this.authStateProvider.authStateProvider(this.instance.id);
await authState.removeCreds();
}
if (cache?.REDIS.ENABLED && cache?.REDIS.SAVE_INSTANCES) {
const authState = await useMultiFileAuthStateRedisDb(this.instance.id, this.cache);
await authState.removeCreds();
}
if (db.SAVE_DATA.INSTANCE) {
const authState = await useMultiFileAuthStatePrisma(this.instance.id, this.cache);
await authState.removeCreds();
}
const sessionExists = await this.prismaRepository.session.findFirst({ where: { sessionId: this.instanceId } }); const sessionExists = await this.prismaRepository.session.findFirst({ where: { sessionId: this.instanceId } });
if (sessionExists) { if (sessionExists) {
await this.prismaRepository.session.delete({ where: { sessionId: this.instanceId } }); await this.prismaRepository.session.delete({ where: { sessionId: this.instanceId } });
@ -595,6 +570,15 @@ export class BaileysStartupService extends ChannelStartupService {
const version = baileysVersion.version; const version = baileysVersion.version;
const log = `Baileys version: ${version.join('.')}`; const log = `Baileys version: ${version.join('.')}`;
// if (session.VERSION) {
// version = session.VERSION.split('.');
// log = `Baileys version env: ${version}`;
// } else {
// const baileysVersion = await fetchLatestWaWebVersion({});
// version = baileysVersion.version;
// log = `Baileys version: ${version}`;
// }
this.logger.info(log); this.logger.info(log);
this.logger.info(`Group Ignore: ${this.localSettings.groupsIgnore}`); this.logger.info(`Group Ignore: ${this.localSettings.groupsIgnore}`);
@ -611,7 +595,7 @@ export class BaileysStartupService extends ChannelStartupService {
const proxyUrls = text.split('\r\n'); const proxyUrls = text.split('\r\n');
const rand = Math.floor(Math.random() * Math.floor(proxyUrls.length)); const rand = Math.floor(Math.random() * Math.floor(proxyUrls.length));
const proxyUrl = 'http://' + proxyUrls[rand]; const proxyUrl = 'http://' + proxyUrls[rand];
options = { agent: makeProxyAgent(proxyUrl), fetchAgent: makeProxyAgentUndici(proxyUrl) }; options = { agent: makeProxyAgent(proxyUrl), fetchAgent: makeProxyAgent(proxyUrl) };
} catch { } catch {
this.localProxy.enabled = false; this.localProxy.enabled = false;
} }
@ -624,7 +608,7 @@ export class BaileysStartupService extends ChannelStartupService {
username: this.localProxy.username, username: this.localProxy.username,
password: this.localProxy.password, password: this.localProxy.password,
}), }),
fetchAgent: makeProxyAgentUndici({ fetchAgent: makeProxyAgent({
host: this.localProxy.host, host: this.localProxy.host,
port: this.localProxy.port, port: this.localProxy.port,
protocol: this.localProxy.protocol, protocol: this.localProxy.protocol,
@ -727,11 +711,6 @@ export class BaileysStartupService extends ChannelStartupService {
this.loadWebhook(); this.loadWebhook();
this.loadProxy(); this.loadProxy();
// Remontar o messageProcessor para garantir que está funcionando após reconexão
this.messageProcessor.mount({
onMessageReceive: this.messageHandle['messages.upsert'].bind(this),
});
return await this.createClient(number); return await this.createClient(number);
} catch (error) { } catch (error) {
this.logger.error(error); this.logger.error(error);
@ -861,12 +840,10 @@ export class BaileysStartupService extends ChannelStartupService {
this.sendDataWebhook(Events.CONTACTS_UPDATE, updatedContacts); this.sendDataWebhook(Events.CONTACTS_UPDATE, updatedContacts);
await Promise.all( await Promise.all(
updatedContacts.map(async (contact) => { updatedContacts.map(async (contact) => {
if (this.configService.get<Database>('DATABASE').SAVE_DATA.CONTACTS) { const update = this.prismaRepository.contact.updateMany({
await this.prismaRepository.contact.updateMany({
where: { remoteJid: contact.remoteJid, instanceId: this.instanceId }, where: { remoteJid: contact.remoteJid, instanceId: this.instanceId },
data: { profilePicUrl: contact.profilePicUrl }, data: { profilePicUrl: contact.profilePicUrl },
}); });
}
if (this.configService.get<Chatwoot>('CHATWOOT').ENABLED && this.localChatwoot?.enabled) { if (this.configService.get<Chatwoot>('CHATWOOT').ENABLED && this.localChatwoot?.enabled) {
const instance = { instanceName: this.instance.name, instanceId: this.instance.id }; const instance = { instanceName: this.instance.name, instanceId: this.instance.id };
@ -885,6 +862,8 @@ export class BaileysStartupService extends ChannelStartupService {
avatar_url: contact.profilePicUrl, avatar_url: contact.profilePicUrl,
}); });
} }
return update;
}), }),
); );
} }
@ -897,7 +876,6 @@ export class BaileysStartupService extends ChannelStartupService {
'contacts.update': async (contacts: Partial<Contact>[]) => { 'contacts.update': async (contacts: Partial<Contact>[]) => {
const contactsRaw: { remoteJid: string; pushName?: string; profilePicUrl?: string; instanceId: string }[] = []; const contactsRaw: { remoteJid: string; pushName?: string; profilePicUrl?: string; instanceId: string }[] = [];
for await (const contact of contacts) { for await (const contact of contacts) {
this.logger.debug(`Updating contact: ${JSON.stringify(contact, null, 2)}`);
contactsRaw.push({ contactsRaw.push({
remoteJid: contact.id, remoteJid: contact.id,
pushName: contact?.name ?? contact?.verifiedName, pushName: contact?.name ?? contact?.verifiedName,
@ -908,7 +886,6 @@ export class BaileysStartupService extends ChannelStartupService {
this.sendDataWebhook(Events.CONTACTS_UPDATE, contactsRaw); this.sendDataWebhook(Events.CONTACTS_UPDATE, contactsRaw);
if (this.configService.get<Database>('DATABASE').SAVE_DATA.CONTACTS) {
const updateTransactions = contactsRaw.map((contact) => const updateTransactions = contactsRaw.map((contact) =>
this.prismaRepository.contact.upsert({ this.prismaRepository.contact.upsert({
where: { remoteJid_instanceId: { remoteJid: contact.remoteJid, instanceId: contact.instanceId } }, where: { remoteJid_instanceId: { remoteJid: contact.remoteJid, instanceId: contact.instanceId } },
@ -917,9 +894,11 @@ export class BaileysStartupService extends ChannelStartupService {
}), }),
); );
await this.prismaRepository.$transaction(updateTransactions); await this.prismaRepository.$transaction(updateTransactions);
}
//const usersContacts = contactsRaw.filter((c) => c.remoteJid.includes('@s.whatsapp')); const usersContacts = contactsRaw.filter((c) => c.remoteJid.includes('@s.whatsapp'));
if (usersContacts) {
await saveOnWhatsappCache(usersContacts.map((c) => ({ remoteJid: c.remoteJid })));
}
}, },
}; };
@ -1046,10 +1025,7 @@ export class BaileysStartupService extends ChannelStartupService {
messagesRaw.push(this.prepareMessage(m)); messagesRaw.push(this.prepareMessage(m));
} }
this.sendDataWebhook(Events.MESSAGES_SET, [...messagesRaw], true, undefined, { this.sendDataWebhook(Events.MESSAGES_SET, [...messagesRaw]);
isLatest,
progress,
});
if (this.configService.get<Database>('DATABASE').SAVE_DATA.HISTORIC) { if (this.configService.get<Database>('DATABASE').SAVE_DATA.HISTORIC) {
await this.prismaRepository.message.createMany({ data: messagesRaw, skipDuplicates: true }); await this.prismaRepository.message.createMany({ data: messagesRaw, skipDuplicates: true });
@ -1095,7 +1071,6 @@ export class BaileysStartupService extends ChannelStartupService {
'Invalid PreKey ID', 'Invalid PreKey ID',
'No session record', 'No session record',
'No session found to decrypt message', 'No session found to decrypt message',
'Message absent from node',
].some((err) => param?.includes?.(err)), ].some((err) => param?.includes?.(err)),
) )
) { ) {
@ -1131,11 +1106,6 @@ export class BaileysStartupService extends ChannelStartupService {
); );
await this.sendDataWebhook(Events.MESSAGES_EDITED, editedMessage); await this.sendDataWebhook(Events.MESSAGES_EDITED, editedMessage);
if (received.key?.id && editedMessage.key?.id) {
await this.baileysCache.set(`protocol_${received.key.id}`, editedMessage.key.id, 60 * 60 * 24);
}
const oldMessage = await this.getMessage(editedMessage.key, true); const oldMessage = await this.getMessage(editedMessage.key, true);
if ((oldMessage as any)?.id) { if ((oldMessage as any)?.id) {
const editedMessageTimestamp = Long.isLong(received?.messageTimestamp) const editedMessageTimestamp = Long.isLong(received?.messageTimestamp)
@ -1163,7 +1133,22 @@ export class BaileysStartupService extends ChannelStartupService {
} }
} }
if ((type !== 'notify' && type !== 'append') || editedMessage || !received?.message) { const messageKey = `${this.instance.id}_${received.key.id}`;
const cached = await this.baileysCache.get(messageKey);
if (cached && !editedMessage) {
this.logger.info(`Message duplicated ignored: ${received.key.id}`);
continue;
}
await this.baileysCache.set(messageKey, true, this.MESSAGE_CACHE_TTL_SECONDS);
if (
(type !== 'notify' && type !== 'append') ||
editedMessage ||
received.message?.pollUpdateMessage ||
!received?.message
) {
continue; continue;
} }
@ -1203,107 +1188,6 @@ export class BaileysStartupService extends ChannelStartupService {
const messageRaw = this.prepareMessage(received); const messageRaw = this.prepareMessage(received);
if (messageRaw.messageType === 'pollUpdateMessage') {
const pollCreationKey = messageRaw.message.pollUpdateMessage.pollCreationMessageKey;
const pollMessage = (await this.getMessage(pollCreationKey, true)) as proto.IWebMessageInfo;
const pollMessageSecret = (await this.getMessage(pollCreationKey)) as any;
if (pollMessage) {
const pollOptions =
(pollMessage.message as any).pollCreationMessage?.options ||
(pollMessage.message as any).pollCreationMessageV3?.options ||
[];
const pollVote = messageRaw.message.pollUpdateMessage.vote;
const voterJid = received.key.fromMe
? this.instance.wuid
: received.key.participant || received.key.remoteJid;
let pollEncKey = pollMessageSecret?.messageContextInfo?.messageSecret;
let successfulVoterJid = voterJid;
if (typeof pollEncKey === 'string') {
pollEncKey = Buffer.from(pollEncKey, 'base64');
} else if (pollEncKey?.type === 'Buffer' && Array.isArray(pollEncKey.data)) {
pollEncKey = Buffer.from(pollEncKey.data);
}
if (Buffer.isBuffer(pollEncKey) && pollEncKey.length === 44) {
pollEncKey = Buffer.from(pollEncKey.toString('utf8'), 'base64');
}
if (pollVote.encPayload && pollEncKey) {
const creatorCandidates = [
this.instance.wuid,
this.client.user?.lid,
pollMessage.key.participant,
(pollMessage.key as any).participantAlt,
pollMessage.key.remoteJid,
];
const key = received.key as any;
const voterCandidates = [
this.instance.wuid,
this.client.user?.lid,
key.participant,
key.participantAlt,
key.remoteJidAlt,
key.remoteJid,
];
const uniqueCreators = [
...new Set(creatorCandidates.filter(Boolean).map((id) => jidNormalizedUser(id))),
];
const uniqueVoters = [...new Set(voterCandidates.filter(Boolean).map((id) => jidNormalizedUser(id)))];
let decryptedVote;
for (const creator of uniqueCreators) {
for (const voter of uniqueVoters) {
try {
decryptedVote = decryptPollVote(pollVote, {
pollCreatorJid: creator,
pollMsgId: pollMessage.key.id,
pollEncKey,
voterJid: voter,
} as any);
if (decryptedVote) {
successfulVoterJid = voter;
break;
}
} catch {
// Continue trying
}
}
if (decryptedVote) break;
}
if (decryptedVote) {
Object.assign(pollVote, decryptedVote);
}
}
const selectedOptions = pollVote?.selectedOptions || [];
const selectedOptionNames = pollOptions
.filter((option) => {
const hash = createHash('sha256').update(option.optionName).digest();
return selectedOptions.some((selected) => Buffer.compare(selected, hash) === 0);
})
.map((option) => option.optionName);
messageRaw.message.pollUpdateMessage.vote.selectedOptions = selectedOptionNames;
const pollUpdates = pollOptions.map((option) => ({
name: option.optionName,
voters: selectedOptionNames.includes(option.optionName) ? [successfulVoterJid] : [],
}));
messageRaw.pollUpdates = pollUpdates;
}
}
const isMedia = const isMedia =
received?.message?.imageMessage || received?.message?.imageMessage ||
received?.message?.videoMessage || received?.message?.videoMessage ||
@ -1353,9 +1237,7 @@ export class BaileysStartupService extends ChannelStartupService {
} }
if (this.configService.get<Database>('DATABASE').SAVE_DATA.NEW_MESSAGE) { if (this.configService.get<Database>('DATABASE').SAVE_DATA.NEW_MESSAGE) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars const msg = await this.prismaRepository.message.create({ data: messageRaw });
const { pollUpdates, ...messageData } = messageRaw;
const msg = await this.prismaRepository.message.create({ data: messageData });
const { remoteJid } = received.key; const { remoteJid } = received.key;
const timestamp = msg.messageTimestamp; const timestamp = msg.messageTimestamp;
@ -1403,11 +1285,6 @@ export class BaileysStartupService extends ChannelStartupService {
} else { } else {
const media = await this.getBase64FromMediaMessage({ message }, true); const media = await this.getBase64FromMediaMessage({ message }, true);
if (!media) {
this.logger.verbose('No valid media to upload (messageContextInfo only), skipping MinIO');
return;
}
const { buffer, mediaType, fileName, size } = media; const { buffer, mediaType, fileName, size } = media;
const mimetype = mimeTypes.lookup(fileName).toString(); const mimetype = mimeTypes.lookup(fileName).toString();
const fullName = join( const fullName = join(
@ -1472,13 +1349,9 @@ export class BaileysStartupService extends ChannelStartupService {
} }
} }
this.logger.verbose(messageRaw); this.logger.log(messageRaw);
sendTelemetry(`received.message.${messageRaw.messageType ?? 'unknown'}`); sendTelemetry(`received.message.${messageRaw.messageType ?? 'unknown'}`);
if (messageRaw.key.remoteJid?.includes('@lid') && messageRaw.key.remoteJidAlt) {
messageRaw.key.remoteJid = messageRaw.key.remoteJidAlt;
}
console.log(messageRaw);
this.sendDataWebhook(Events.MESSAGES_UPSERT, messageRaw); this.sendDataWebhook(Events.MESSAGES_UPSERT, messageRaw);
@ -1493,12 +1366,7 @@ export class BaileysStartupService extends ChannelStartupService {
where: { remoteJid: received.key.remoteJid, instanceId: this.instanceId }, where: { remoteJid: received.key.remoteJid, instanceId: this.instanceId },
}); });
const contactRaw: { const contactRaw: { remoteJid: string; pushName: string; profilePicUrl?: string; instanceId: string } = {
remoteJid: string;
pushName: string;
profilePicUrl?: string;
instanceId: string;
} = {
remoteJid: received.key.remoteJid, remoteJid: received.key.remoteJid,
pushName: received.key.fromMe ? '' : received.key.fromMe == null ? '' : received.pushName, pushName: received.key.fromMe ? '' : received.key.fromMe == null ? '' : received.pushName,
profilePicUrl: (await this.profilePicture(received.key.remoteJid)).profilePictureUrl, profilePicUrl: (await this.profilePicture(received.key.remoteJid)).profilePictureUrl,
@ -1509,17 +1377,6 @@ export class BaileysStartupService extends ChannelStartupService {
continue; continue;
} }
if (contactRaw.remoteJid.includes('@s.whatsapp') || contactRaw.remoteJid.includes('@lid')) {
await saveOnWhatsappCache([
{
remoteJid:
messageRaw.key.addressingMode === 'lid' ? messageRaw.key.remoteJidAlt : messageRaw.key.remoteJid,
remoteJidAlt: messageRaw.key.remoteJidAlt,
lid: messageRaw.key.addressingMode === 'lid' ? 'lid' : null,
},
]);
}
if (contact) { if (contact) {
this.sendDataWebhook(Events.CONTACTS_UPDATE, contactRaw); this.sendDataWebhook(Events.CONTACTS_UPDATE, contactRaw);
@ -1549,6 +1406,10 @@ export class BaileysStartupService extends ChannelStartupService {
update: contactRaw, update: contactRaw,
create: contactRaw, create: contactRaw,
}); });
if (contactRaw.remoteJid.includes('@s.whatsapp')) {
await saveOnWhatsappCache([{ remoteJid: contactRaw.remoteJid }]);
}
} }
} catch (error) { } catch (error) {
this.logger.error(error); this.logger.error(error);
@ -1556,7 +1417,7 @@ export class BaileysStartupService extends ChannelStartupService {
}, },
'messages.update': async (args: { update: Partial<WAMessage>; key: WAMessageKey }[], settings: any) => { 'messages.update': async (args: { update: Partial<WAMessage>; key: WAMessageKey }[], settings: any) => {
this.logger.verbose(`Update messages ${JSON.stringify(args, undefined, 2)}`); this.logger.log(`Update messages ${JSON.stringify(args, undefined, 2)}`);
const readChatToUpdate: Record<string, true> = {}; // {remoteJid: true} const readChatToUpdate: Record<string, true> = {}; // {remoteJid: true}
@ -1565,26 +1426,18 @@ export class BaileysStartupService extends ChannelStartupService {
continue; continue;
} }
if (update.message !== null && update.status === undefined) continue;
const updateKey = `${this.instance.id}_${key.id}_${update.status}`; const updateKey = `${this.instance.id}_${key.id}_${update.status}`;
const cached = await this.baileysCache.get(updateKey); const cached = await this.baileysCache.get(updateKey);
const secondsSinceEpoch = Math.floor(Date.now() / 1000); if (cached) {
console.log('CACHE:', { cached, updateKey, messageTimestamp: update.messageTimestamp, secondsSinceEpoch }); this.logger.info(`Message duplicated ignored [avoid deadlock]: ${updateKey}`);
if (
(update.messageTimestamp && update.messageTimestamp === cached) ||
(!update.messageTimestamp && secondsSinceEpoch === cached)
) {
this.logger.info(`Update Message duplicated ignored [avoid deadlock]: ${updateKey}`);
continue; continue;
} }
if (update.messageTimestamp) { await this.baileysCache.set(updateKey, true, 30 * 60);
await this.baileysCache.set(updateKey, update.messageTimestamp, 30 * 60);
} else {
await this.baileysCache.set(updateKey, secondsSinceEpoch, 30 * 60);
}
if (status[update.status] === 'READ' && key.fromMe) { if (status[update.status] === 'READ' && key.fromMe) {
if (this.configService.get<Chatwoot>('CHATWOOT').ENABLED && this.localChatwoot?.enabled) { if (this.configService.get<Chatwoot>('CHATWOOT').ENABLED && this.localChatwoot?.enabled) {
@ -1615,32 +1468,19 @@ export class BaileysStartupService extends ChannelStartupService {
remoteJid: key?.remoteJid, remoteJid: key?.remoteJid,
fromMe: key.fromMe, fromMe: key.fromMe,
participant: key?.participant, participant: key?.participant,
status: status[update.status] ?? 'SERVER_ACK', status: status[update.status] ?? 'DELETED',
pollUpdates, pollUpdates,
instanceId: this.instanceId, instanceId: this.instanceId,
}; };
if (update.message) {
message.message = update.message;
}
let findMessage: any; let findMessage: any;
const configDatabaseData = this.configService.get<Database>('DATABASE').SAVE_DATA; const configDatabaseData = this.configService.get<Database>('DATABASE').SAVE_DATA;
if (configDatabaseData.HISTORIC || configDatabaseData.NEW_MESSAGE) { if (configDatabaseData.HISTORIC || configDatabaseData.NEW_MESSAGE) {
// Use raw SQL to avoid JSON path issues // Use raw SQL to avoid JSON path issues
const protocolMapKey = `protocol_${key.id}`;
const originalMessageId = (await this.baileysCache.get(protocolMapKey)) as string;
if (originalMessageId) {
message.keyId = originalMessageId;
}
const searchId = originalMessageId || key.id;
const messages = (await this.prismaRepository.$queryRaw` const messages = (await this.prismaRepository.$queryRaw`
SELECT * FROM "Message" SELECT * FROM "Message"
WHERE "instanceId" = ${this.instanceId} WHERE "instanceId" = ${this.instanceId}
AND "key"->>'id' = ${searchId} AND "key"->>'id' = ${key.id}
LIMIT 1 LIMIT 1
`) as any[]; `) as any[];
findMessage = messages[0] || null; findMessage = messages[0] || null;
@ -1653,7 +1493,7 @@ export class BaileysStartupService extends ChannelStartupService {
} }
if (update.message === null && update.status === undefined) { if (update.message === null && update.status === undefined) {
this.sendDataWebhook(Events.MESSAGES_DELETE, { ...key, status: 'DELETED' }); this.sendDataWebhook(Events.MESSAGES_DELETE, key);
if (this.configService.get<Database>('DATABASE').SAVE_DATA.MESSAGE_UPDATE) if (this.configService.get<Database>('DATABASE').SAVE_DATA.MESSAGE_UPDATE)
await this.prismaRepository.messageUpdate.create({ data: message }); await this.prismaRepository.messageUpdate.create({ data: message });
@ -1701,11 +1541,8 @@ export class BaileysStartupService extends ChannelStartupService {
this.sendDataWebhook(Events.MESSAGES_UPDATE, message); this.sendDataWebhook(Events.MESSAGES_UPDATE, message);
if (this.configService.get<Database>('DATABASE').SAVE_DATA.MESSAGE_UPDATE) { if (this.configService.get<Database>('DATABASE').SAVE_DATA.MESSAGE_UPDATE)
// eslint-disable-next-line @typescript-eslint/no-unused-vars await this.prismaRepository.messageUpdate.create({ data: message });
const { message: _msg, ...messageData } = message;
await this.prismaRepository.messageUpdate.create({ data: messageData });
}
const existingChat = await this.prismaRepository.chat.findFirst({ const existingChat = await this.prismaRepository.chat.findFirst({
where: { instanceId: this.instanceId, remoteJid: message.remoteJid }, where: { instanceId: this.instanceId, remoteJid: message.remoteJid },
@ -1756,9 +1593,9 @@ export class BaileysStartupService extends ChannelStartupService {
// This enables LID to phoneNumber conversion without breaking existing webhook consumers // This enables LID to phoneNumber conversion without breaking existing webhook consumers
// Helper to normalize participantId as phone number // Helper to normalize participantId as phone number
const normalizePhoneNumber = (id: string | null | undefined): string => { const normalizePhoneNumber = (id: string): string => {
// Remove @lid, @s.whatsapp.net suffixes and extract just the number part // Remove @lid, @s.whatsapp.net suffixes and extract just the number part
return String(id || '').split('@')[0]; return id.split('@')[0];
}; };
try { try {
@ -1874,8 +1711,6 @@ export class BaileysStartupService extends ChannelStartupService {
private eventHandler() { private eventHandler() {
this.client.ev.process(async (events) => { this.client.ev.process(async (events) => {
this.eventProcessingQueue = this.eventProcessingQueue.then(async () => {
try {
if (!this.endSession) { if (!this.endSession) {
const database = this.configService.get<Database>('DATABASE'); const database = this.configService.get<Database>('DATABASE');
const settings = await this.findSettings(); const settings = await this.findSettings();
@ -1909,19 +1744,19 @@ export class BaileysStartupService extends ChannelStartupService {
if (events['messaging-history.set']) { if (events['messaging-history.set']) {
const payload = events['messaging-history.set']; const payload = events['messaging-history.set'];
await this.messageHandle['messaging-history.set'](payload); this.messageHandle['messaging-history.set'](payload);
} }
if (events['messages.upsert']) { if (events['messages.upsert']) {
const payload = events['messages.upsert']; const payload = events['messages.upsert'];
// this.messageProcessor.processMessage(payload, settings); this.messageProcessor.processMessage(payload, settings);
await this.messageHandle['messages.upsert'](payload, settings); // this.messageHandle['messages.upsert'](payload, settings);
} }
if (events['messages.update']) { if (events['messages.update']) {
const payload = events['messages.update']; const payload = events['messages.update'];
await this.messageHandle['messages.update'](payload, settings); this.messageHandle['messages.update'](payload, settings);
} }
if (events['message-receipt.update']) { if (events['message-receipt.update']) {
@ -1963,7 +1798,7 @@ export class BaileysStartupService extends ChannelStartupService {
} }
if (events['group-participants.update']) { if (events['group-participants.update']) {
const payload = events['group-participants.update'] as any; const payload = events['group-participants.update'];
this.groupHandler['group-participants.update'](payload); this.groupHandler['group-participants.update'](payload);
} }
} }
@ -2005,10 +1840,6 @@ export class BaileysStartupService extends ChannelStartupService {
return; return;
} }
} }
} catch (error) {
this.logger.error(error);
}
});
}); });
} }
@ -2135,7 +1966,6 @@ export class BaileysStartupService extends ChannelStartupService {
quoted: any, quoted: any,
messageId?: string, messageId?: string,
ephemeralExpiration?: number, ephemeralExpiration?: number,
contextInfo?: any,
// participants?: GroupParticipant[], // participants?: GroupParticipant[],
) { ) {
sender = sender.toLowerCase(); sender = sender.toLowerCase();
@ -2152,8 +1982,8 @@ export class BaileysStartupService extends ChannelStartupService {
if (ephemeralExpiration) option.ephemeralExpiration = ephemeralExpiration; if (ephemeralExpiration) option.ephemeralExpiration = ephemeralExpiration;
// NOTE: NÃO DEVEMOS GERAR O messageId AQUI, SOMENTE SE VIER INFORMADO POR PARAMETRO. A GERAÇÃO ANTERIOR IMPEDE O WZAP DE IDENTIFICAR A SOURCE.
if (messageId) option.messageId = messageId; if (messageId) option.messageId = messageId;
else option.messageId = '3EB0' + randomBytes(18).toString('hex').toUpperCase();
if (message['viewOnceMessage']) { if (message['viewOnceMessage']) {
const m = generateWAMessageFromContent(sender, message, { const m = generateWAMessageFromContent(sender, message, {
@ -2190,19 +2020,10 @@ export class BaileysStartupService extends ChannelStartupService {
} }
} }
if (contextInfo) {
message['contextInfo'] = contextInfo;
}
if (message['conversation']) { if (message['conversation']) {
return await this.client.sendMessage( return await this.client.sendMessage(
sender, sender,
{ { text: message['conversation'], mentions, linkPreview: linkPreview } as unknown as AnyMessageContent,
text: message['conversation'],
mentions,
linkPreview: linkPreview,
contextInfo: message['contextInfo'],
} as unknown as AnyMessageContent,
option as unknown as MiscMessageGenerationOptions, option as unknown as MiscMessageGenerationOptions,
); );
} }
@ -2210,11 +2031,7 @@ export class BaileysStartupService extends ChannelStartupService {
if (!message['audio'] && !message['poll'] && !message['sticker'] && sender != 'status@broadcast') { if (!message['audio'] && !message['poll'] && !message['sticker'] && sender != 'status@broadcast') {
return await this.client.sendMessage( return await this.client.sendMessage(
sender, sender,
{ { forward: { key: { remoteJid: this.instance.wuid, fromMe: true }, message }, mentions },
forward: { key: { remoteJid: this.instance.wuid, fromMe: true }, message },
mentions,
contextInfo: message['contextInfo'],
},
option as unknown as MiscMessageGenerationOptions, option as unknown as MiscMessageGenerationOptions,
); );
} }
@ -2345,7 +2162,7 @@ export class BaileysStartupService extends ChannelStartupService {
if (options?.quoted) { if (options?.quoted) {
const m = options?.quoted; const m = options?.quoted;
const msg = m?.message ? m : ((await this.getMessage(m.key, true)) as WAMessage); const msg = m?.message ? m : ((await this.getMessage(m.key, true)) as proto.IWebMessageInfo);
if (msg) { if (msg) {
quoted = msg; quoted = msg;
@ -2355,8 +2172,6 @@ export class BaileysStartupService extends ChannelStartupService {
let messageSent: WAMessage; let messageSent: WAMessage;
let mentions: string[]; let mentions: string[];
let contextInfo: any;
if (isJidGroup(sender)) { if (isJidGroup(sender)) {
let group; let group;
try { try {
@ -2395,27 +2210,7 @@ export class BaileysStartupService extends ChannelStartupService {
// group?.participants, // group?.participants,
); );
} else { } else {
contextInfo = { messageSent = await this.sendMessage(sender, message, mentions, linkPreview, quoted);
mentionedJid: [],
groupMentions: [],
//expiration: 7776000,
ephemeralSettingTimestamp: {
low: Math.floor(Date.now() / 1000) - 172800,
high: 0,
unsigned: false,
},
disappearingMode: { initiator: 0 },
};
messageSent = await this.sendMessage(
sender,
message,
mentions,
linkPreview,
quoted,
null,
undefined,
contextInfo,
);
} }
if (Long.isLong(messageSent?.messageTimestamp)) { if (Long.isLong(messageSent?.messageTimestamp)) {
@ -2474,11 +2269,6 @@ export class BaileysStartupService extends ChannelStartupService {
} else { } else {
const media = await this.getBase64FromMediaMessage({ message }, true); const media = await this.getBase64FromMediaMessage({ message }, true);
if (!media) {
this.logger.verbose('No valid media to upload (messageContextInfo only), skipping MinIO');
return;
}
const { buffer, mediaType, fileName, size } = media; const { buffer, mediaType, fileName, size } = media;
const mimetype = mimeTypes.lookup(fileName).toString(); const mimetype = mimeTypes.lookup(fileName).toString();
@ -2540,7 +2330,7 @@ export class BaileysStartupService extends ChannelStartupService {
} }
} }
this.logger.verbose(messageSent); this.logger.log(messageRaw);
this.sendDataWebhook(Events.SEND_MESSAGE, messageRaw); this.sendDataWebhook(Events.SEND_MESSAGE, messageRaw);
@ -3547,55 +3337,42 @@ export class BaileysStartupService extends ChannelStartupService {
where: { instanceId: this.instanceId, remoteJid: { in: jids.users.map(({ jid }) => jid) } }, where: { instanceId: this.instanceId, remoteJid: { in: jids.users.map(({ jid }) => jid) } },
}); });
// Unified cache verification for all numbers (normal and LID) // Separate @lid numbers from normal numbers
const numbersToVerify = jids.users.map(({ jid }) => jid.replace('+', '')); const lidUsers = jids.users.filter(({ jid }) => jid.includes('@lid'));
const normalUsers = jids.users.filter(({ jid }) => !jid.includes('@lid'));
// For normal numbers, use traditional Baileys verification
let normalVerifiedUsers: OnWhatsAppDto[] = [];
if (normalUsers.length > 0) {
console.log('normalUsers', normalUsers);
const numbersToVerify = normalUsers.map(({ jid }) => jid.replace('+', ''));
console.log('numbersToVerify', numbersToVerify);
// Get all numbers from cache
const cachedNumbers = await getOnWhatsappCache(numbersToVerify); const cachedNumbers = await getOnWhatsappCache(numbersToVerify);
console.log('cachedNumbers', cachedNumbers);
// Separate numbers that are and are not in cache const filteredNumbers = numbersToVerify.filter(
const cachedJids = new Set(cachedNumbers.flatMap((cached) => cached.jidOptions)); (jid) => !cachedNumbers.some((cached) => cached.jidOptions.includes(jid)),
const numbersNotInCache = numbersToVerify.filter((jid) => !cachedJids.has(jid)); );
console.log('filteredNumbers', filteredNumbers);
// Only call Baileys for normal numbers (@s.whatsapp.net) that are not in cache const verify = await this.client.onWhatsApp(...filteredNumbers);
let verify: { jid: string; exists: boolean }[] = []; console.log('verify', verify);
const normalNumbersNotInCache = numbersNotInCache.filter((jid) => !jid.includes('@lid')); normalVerifiedUsers = await Promise.all(
normalUsers.map(async (user) => {
let numberVerified: (typeof verify)[0] | null = null;
if (normalNumbersNotInCache.length > 0) {
this.logger.verbose(`Checking ${normalNumbersNotInCache.length} numbers via Baileys (not found in cache)`);
verify = await this.client.onWhatsApp(...normalNumbersNotInCache);
}
const verifiedUsers = await Promise.all(
jids.users.map(async (user) => {
// Try to get from cache first (works for all: normal and LID)
const cached = cachedNumbers.find((cached) => cached.jidOptions.includes(user.jid.replace('+', ''))); const cached = cachedNumbers.find((cached) => cached.jidOptions.includes(user.jid.replace('+', '')));
if (cached) { if (cached) {
this.logger.verbose(`Number ${user.number} found in cache`);
return new OnWhatsAppDto( return new OnWhatsAppDto(
cached.remoteJid, cached.remoteJid,
true, true,
user.number, user.number,
contacts.find((c) => c.remoteJid === cached.remoteJid)?.pushName, contacts.find((c) => c.remoteJid === cached.remoteJid)?.pushName,
cached.lid || (cached.remoteJid.includes('@lid') ? 'lid' : undefined), cached.lid || (cached.remoteJid.includes('@lid') ? cached.remoteJid.split('@')[1] : undefined),
); );
} }
// If it's a LID number and not in cache, consider it valid
if (user.jid.includes('@lid')) {
return new OnWhatsAppDto(
user.jid,
true,
user.number,
contacts.find((c) => c.remoteJid === user.jid)?.pushName,
'lid',
);
}
// If not in cache and is a normal number, use Baileys verification
let numberVerified: (typeof verify)[0] | null = null;
// Brazilian numbers // Brazilian numbers
if (user.number.startsWith('55')) { if (user.number.startsWith('55')) {
const numberWithDigit = const numberWithDigit =
@ -3648,27 +3425,32 @@ export class BaileysStartupService extends ChannelStartupService {
); );
}), }),
); );
}
// Combine results // For @lid numbers, always consider them as valid
onWhatsapp.push(...verifiedUsers); const lidVerifiedUsers: OnWhatsAppDto[] = lidUsers.map((user) => {
return new OnWhatsAppDto(
// TODO: Salvar no cache apenas números que NÃO estavam no cache user.jid,
const numbersToCache = onWhatsapp.filter((user) => { true,
if (!user.exists) return false; user.number,
// Verifica se estava no cache usando jidOptions contacts.find((c) => c.remoteJid === user.jid)?.pushName,
const cached = cachedNumbers?.find((cached) => cached.jidOptions.includes(user.jid.replace('+', ''))); user.jid.split('@')[1],
return !cached; );
}); });
if (numbersToCache.length > 0) { // Combine results
this.logger.verbose(`Salvando ${numbersToCache.length} números no cache`); onWhatsapp.push(...normalVerifiedUsers, ...lidVerifiedUsers);
// Save to cache only valid numbers
await saveOnWhatsappCache( await saveOnWhatsappCache(
numbersToCache.map((user) => ({ onWhatsapp
.filter((user) => user.exists)
.map((user) => ({
remoteJid: user.jid, remoteJid: user.jid,
lid: user.lid === 'lid' ? 'lid' : undefined, jidOptions: user.jid.replace('+', ''),
lid: user.lid,
})), })),
); );
}
return onWhatsapp; return onWhatsapp;
} }
@ -3851,9 +3633,11 @@ export class BaileysStartupService extends ChannelStartupService {
} }
} }
if ('messageContextInfo' in msg.message && Object.keys(msg.message).length === 1) { if (
this.logger.verbose('Message contains only messageContextInfo, skipping media processing'); Object.keys(msg.message).length === 1 &&
return null; Object.prototype.hasOwnProperty.call(msg.message, 'messageContextInfo')
) {
throw 'The message is messageContextInfo';
} }
let mediaMessage: any; let mediaMessage: any;
@ -4610,37 +4394,24 @@ export class BaileysStartupService extends ChannelStartupService {
throw new Error('Method not available in the Baileys service'); throw new Error('Method not available in the Baileys service');
} }
private deserializeMessageBuffers(obj: any): any { private convertLongToNumber(obj: any): any {
if (obj === null || obj === undefined) { if (obj === null || obj === undefined) {
return obj; return obj;
} }
if (typeof obj === 'object' && !Array.isArray(obj) && !Buffer.isBuffer(obj)) { if (Long.isLong(obj)) {
const keys = Object.keys(obj); return obj.toNumber();
const isIndexedObject = keys.every((key) => !isNaN(Number(key)));
if (isIndexedObject && keys.length > 0) {
const values = keys.sort((a, b) => Number(a) - Number(b)).map((key) => obj[key]);
return new Uint8Array(values);
}
} }
// Is Buffer?, converter to Uint8Array
if (Buffer.isBuffer(obj)) {
return new Uint8Array(obj);
}
// Process arrays recursively
if (Array.isArray(obj)) { if (Array.isArray(obj)) {
return obj.map((item) => this.deserializeMessageBuffers(item)); return obj.map((item) => this.convertLongToNumber(item));
} }
// Process objects recursively
if (typeof obj === 'object') { if (typeof obj === 'object') {
const converted: any = {}; const converted: any = {};
for (const key in obj) { for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) { if (Object.prototype.hasOwnProperty.call(obj, key)) {
converted[key] = this.deserializeMessageBuffers(obj[key]); converted[key] = this.convertLongToNumber(obj[key]);
} }
} }
return converted; return converted;
@ -4661,8 +4432,8 @@ export class BaileysStartupService extends ChannelStartupService {
? 'Você' ? 'Você'
: message?.participant || (message.key?.participant ? message.key.participant.split('@')[0] : null)), : message?.participant || (message.key?.participant ? message.key.participant.split('@')[0] : null)),
status: status[message.status], status: status[message.status],
message: this.deserializeMessageBuffers({ ...message.message }), message: this.convertLongToNumber({ ...message.message }),
contextInfo: this.deserializeMessageBuffers(contentMsg?.contextInfo), contextInfo: this.convertLongToNumber(contentMsg?.contextInfo),
messageType: contentType || 'unknown', messageType: contentType || 'unknown',
messageTimestamp: Long.isLong(message.messageTimestamp) messageTimestamp: Long.isLong(message.messageTimestamp)
? message.messageTimestamp.toNumber() ? message.messageTimestamp.toNumber()
@ -5036,6 +4807,7 @@ export class BaileysStartupService extends ChannelStartupService {
AND: [ AND: [
keyFilters?.id ? { key: { path: ['id'], equals: keyFilters?.id } } : {}, keyFilters?.id ? { key: { path: ['id'], equals: keyFilters?.id } } : {},
keyFilters?.fromMe ? { key: { path: ['fromMe'], equals: keyFilters?.fromMe } } : {}, keyFilters?.fromMe ? { key: { path: ['fromMe'], equals: keyFilters?.fromMe } } : {},
keyFilters?.remoteJid ? { key: { path: ['remoteJid'], equals: keyFilters?.remoteJid } } : {},
keyFilters?.participant ? { key: { path: ['participant'], equals: keyFilters?.participant } } : {}, keyFilters?.participant ? { key: { path: ['participant'], equals: keyFilters?.participant } } : {},
{ {
OR: [ OR: [
@ -5065,6 +4837,7 @@ export class BaileysStartupService extends ChannelStartupService {
AND: [ AND: [
keyFilters?.id ? { key: { path: ['id'], equals: keyFilters?.id } } : {}, keyFilters?.id ? { key: { path: ['id'], equals: keyFilters?.id } } : {},
keyFilters?.fromMe ? { key: { path: ['fromMe'], equals: keyFilters?.fromMe } } : {}, keyFilters?.fromMe ? { key: { path: ['fromMe'], equals: keyFilters?.fromMe } } : {},
keyFilters?.remoteJid ? { key: { path: ['remoteJid'], equals: keyFilters?.remoteJid } } : {},
keyFilters?.participant ? { key: { path: ['participant'], equals: keyFilters?.participant } } : {}, keyFilters?.participant ? { key: { path: ['participant'], equals: keyFilters?.participant } } : {},
{ {
OR: [ OR: [

View File

@ -211,7 +211,7 @@ export abstract class BaseChatbotService<BotType = any, SettingsType = any> {
try { try {
if (mediaType === 'audio') { if (mediaType === 'audio') {
await instance.audioWhatsapp({ await instance.audioWhatsapp({
number: remoteJid.includes('@lid') ? remoteJid : remoteJid.split('@')[0], number: remoteJid.split('@')[0],
delay: (settings as any)?.delayMessage || 1000, delay: (settings as any)?.delayMessage || 1000,
audio: url, audio: url,
caption: altText, caption: altText,
@ -219,7 +219,7 @@ export abstract class BaseChatbotService<BotType = any, SettingsType = any> {
} else { } else {
await instance.mediaMessage( await instance.mediaMessage(
{ {
number: remoteJid.includes('@lid') ? remoteJid : remoteJid.split('@')[0], number: remoteJid.split('@')[0],
delay: (settings as any)?.delayMessage || 1000, delay: (settings as any)?.delayMessage || 1000,
mediatype: mediaType, mediatype: mediaType,
media: url, media: url,
@ -290,7 +290,7 @@ export abstract class BaseChatbotService<BotType = any, SettingsType = any> {
setTimeout(async () => { setTimeout(async () => {
await instance.textMessage( await instance.textMessage(
{ {
number: remoteJid.includes('@lid') ? remoteJid : remoteJid.split('@')[0], number: remoteJid.split('@')[0],
delay: settings?.delayMessage || 1000, delay: settings?.delayMessage || 1000,
text: message, text: message,
linkPreview, linkPreview,

View File

@ -1,6 +1,10 @@
import { InstanceDto } from '@api/dto/instance.dto'; import { InstanceDto } from '@api/dto/instance.dto';
import { ChatwootDto } from '@api/integrations/chatbot/chatwoot/dto/chatwoot.dto'; import { ChatwootDto } from '@api/integrations/chatbot/chatwoot/dto/chatwoot.dto';
import { ChatwootService } from '@api/integrations/chatbot/chatwoot/services/chatwoot.service'; import { ChatwootService } from '@api/integrations/chatbot/chatwoot/services/chatwoot.service';
import { PrismaRepository } from '@api/repository/repository.service';
import { waMonitor } from '@api/server.module';
import { CacheService } from '@api/services/cache.service';
import { CacheEngine } from '@cache/cacheengine';
import { Chatwoot, ConfigService, HttpServer } from '@config/env.config'; import { Chatwoot, ConfigService, HttpServer } from '@config/env.config';
import { BadRequestException } from '@exceptions'; import { BadRequestException } from '@exceptions';
import { isURL } from 'class-validator'; import { isURL } from 'class-validator';
@ -9,6 +13,7 @@ export class ChatwootController {
constructor( constructor(
private readonly chatwootService: ChatwootService, private readonly chatwootService: ChatwootService,
private readonly configService: ConfigService, private readonly configService: ConfigService,
private readonly prismaRepository: PrismaRepository,
) {} ) {}
public async createChatwoot(instance: InstanceDto, data: ChatwootDto) { public async createChatwoot(instance: InstanceDto, data: ChatwootDto) {
@ -79,6 +84,9 @@ export class ChatwootController {
public async receiveWebhook(instance: InstanceDto, data: any) { public async receiveWebhook(instance: InstanceDto, data: any) {
if (!this.configService.get<Chatwoot>('CHATWOOT').ENABLED) throw new BadRequestException('Chatwoot is disabled'); if (!this.configService.get<Chatwoot>('CHATWOOT').ENABLED) throw new BadRequestException('Chatwoot is disabled');
return this.chatwootService.receiveWebhook(instance, data); const chatwootCache = new CacheService(new CacheEngine(this.configService, ChatwootService.name).getEngine());
const chatwootService = new ChatwootService(waMonitor, this.configService, this.prismaRepository, chatwootCache);
return chatwootService.receiveWebhook(instance, data);
} }
} }

View File

@ -23,16 +23,17 @@ import { Chatwoot as ChatwootModel, Contact as ContactModel, Message as MessageM
import i18next from '@utils/i18n'; import i18next from '@utils/i18n';
import { sendTelemetry } from '@utils/sendTelemetry'; import { sendTelemetry } from '@utils/sendTelemetry';
import axios from 'axios'; import axios from 'axios';
import { WAMessageContent, WAMessageKey } from 'baileys'; import { proto, WAMessageKey } from 'baileys';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import FormData from 'form-data'; import FormData from 'form-data';
import { Jimp, JimpMime } from 'jimp'; import { Jimp, JimpMime } from 'jimp';
import { parsePhoneNumberFromString } from 'libphonenumber-js';
import Long from 'long'; import Long from 'long';
import mimeTypes from 'mime-types'; import mimeTypes from 'mime-types';
import path from 'path'; import path from 'path';
import { Readable } from 'stream'; import { Readable } from 'stream';
const MIN_CONNECTION_NOTIFICATION_INTERVAL_MS = 30000; // 30 seconds
interface ChatwootMessage { interface ChatwootMessage {
messageId?: number; messageId?: number;
inboxId?: number; inboxId?: number;
@ -44,6 +45,22 @@ interface ChatwootMessage {
export class ChatwootService { export class ChatwootService {
private readonly logger = new Logger('ChatwootService'); private readonly logger = new Logger('ChatwootService');
// HTTP timeout constants
private readonly MEDIA_DOWNLOAD_TIMEOUT_MS = 60000; // 60 seconds for large files
// S3/MinIO retry configuration (external storage - longer delays, fewer retries)
private readonly S3_MAX_RETRIES = 3;
private readonly S3_BASE_DELAY_MS = 1000; // Base delay: 1 second
private readonly S3_MAX_DELAY_MS = 8000; // Max delay: 8 seconds
// Database polling retry configuration (internal DB - shorter delays, more retries)
private readonly DB_POLLING_MAX_RETRIES = 5;
private readonly DB_POLLING_BASE_DELAY_MS = 100; // Base delay: 100ms
private readonly DB_POLLING_MAX_DELAY_MS = 2000; // Max delay: 2 seconds
// Webhook processing delay
private readonly WEBHOOK_INITIAL_DELAY_MS = 500; // Initial delay before processing webhook
// Lock polling delay // Lock polling delay
private readonly LOCK_POLLING_DELAY_MS = 300; // Delay between lock status checks private readonly LOCK_POLLING_DELAY_MS = 300; // Delay between lock status checks
@ -346,16 +363,6 @@ export class ChatwootService {
return contact; return contact;
} catch (error) { } catch (error) {
if ((error.status === 422 || error.response?.status === 422) && jid) {
this.logger.warn(`Contact with identifier ${jid} creation failed (422). Checking if it already exists...`);
const existingContact = await this.findContactByIdentifier(instance, jid);
if (existingContact) {
const contactId = existingContact.id;
await this.addLabelToContact(this.provider.nameInbox, contactId);
return existingContact;
}
}
this.logger.error('Error creating contact'); this.logger.error('Error creating contact');
console.log(error); console.log(error);
return null; return null;
@ -425,55 +432,6 @@ export class ChatwootService {
} }
} }
public async findContactByIdentifier(instance: InstanceDto, identifier: string) {
const client = await this.clientCw(instance);
if (!client) {
this.logger.warn('client not found');
return null;
}
// Direct search by query (q) - most common way to search by identifier/email/phone
const contact = (await (client as any).get('contacts/search', {
params: {
q: identifier,
sort: 'name',
},
})) as any;
if (contact && contact.data && contact.data.payload && contact.data.payload.length > 0) {
return contact.data.payload[0];
}
// Fallback for older API versions or different response structures
if (contact && contact.payload && contact.payload.length > 0) {
return contact.payload[0];
}
// Try search by attribute
const contactByAttr = (await (client as any).post('contacts/filter', {
payload: [
{
attribute_key: 'identifier',
filter_operator: 'equal_to',
values: [identifier],
query_operator: null,
},
],
})) as any;
if (contactByAttr && contactByAttr.payload && contactByAttr.payload.length > 0) {
return contactByAttr.payload[0];
}
// Check inside data property if using axios interceptors wrapper
if (contactByAttr && contactByAttr.data && contactByAttr.data.payload && contactByAttr.data.payload.length > 0) {
return contactByAttr.data.payload[0];
}
return null;
}
public async findContact(instance: InstanceDto, phoneNumber: string) { public async findContact(instance: InstanceDto, phoneNumber: string) {
const client = await this.clientCw(instance); const client = await this.clientCw(instance);
@ -630,10 +588,8 @@ export class ChatwootService {
} }
public async createConversation(instance: InstanceDto, body: any) { public async createConversation(instance: InstanceDto, body: any) {
const isLid = body.key.addressingMode === 'lid'; const isLid = body.key.addressingMode === 'lid' && body.key.remoteJidAlt;
const isGroup = body.key.remoteJid.endsWith('@g.us'); const remoteJid = isLid ? body.key.remoteJidAlt : body.key.remoteJid;
const phoneNumber = isLid && !isGroup ? body.key.remoteJidAlt : body.key.remoteJid;
const { remoteJid } = body.key;
const cacheKey = `${instance.instanceName}:createConversation-${remoteJid}`; const cacheKey = `${instance.instanceName}:createConversation-${remoteJid}`;
const lockKey = `${instance.instanceName}:lock:createConversation-${remoteJid}`; const lockKey = `${instance.instanceName}:lock:createConversation-${remoteJid}`;
const maxWaitTime = 5000; // 5 seconds const maxWaitTime = 5000; // 5 seconds
@ -642,19 +598,19 @@ export class ChatwootService {
try { try {
// Processa atualização de contatos já criados @lid // Processa atualização de contatos já criados @lid
if (phoneNumber && remoteJid && !isGroup) { if (isLid && body.key.remoteJidAlt !== body.key.remoteJid) {
const contact = await this.findContact(instance, phoneNumber.split('@')[0]); const contact = await this.findContact(instance, body.key.remoteJid.split('@')[0]);
if (contact && contact.identifier !== remoteJid) { if (contact && contact.identifier !== body.key.remoteJidAlt) {
this.logger.verbose( this.logger.verbose(
`Identifier needs update: (contact.identifier: ${contact.identifier}, phoneNumber: ${phoneNumber}, body.key.remoteJidAlt: ${remoteJid}`, `Identifier needs update: (contact.identifier: ${contact.identifier}, body.key.remoteJid: ${body.key.remoteJid}, body.key.remoteJidAlt: ${body.key.remoteJidAlt}`,
); );
const updateContact = await this.updateContact(instance, contact.id, { const updateContact = await this.updateContact(instance, contact.id, {
identifier: phoneNumber, identifier: body.key.remoteJidAlt,
phone_number: `+${phoneNumber.split('@')[0]}`, phone_number: `+${body.key.remoteJidAlt.split('@')[0]}`,
}); });
if (updateContact === null) { if (updateContact === null) {
const baseContact = await this.findContact(instance, phoneNumber.split('@')[0]); const baseContact = await this.findContact(instance, body.key.remoteJidAlt.split('@')[0]);
if (baseContact) { if (baseContact) {
await this.mergeContacts(baseContact.id, contact.id); await this.mergeContacts(baseContact.id, contact.id);
this.logger.verbose( this.logger.verbose(
@ -670,16 +626,14 @@ export class ChatwootService {
// If it already exists in the cache, return conversationId // If it already exists in the cache, return conversationId
if (await this.cache.has(cacheKey)) { if (await this.cache.has(cacheKey)) {
const conversationId = (await this.cache.get(cacheKey)) as number; const conversationId = (await this.cache.get(cacheKey)) as number;
this.logger.verbose(`Found conversation to: ${phoneNumber}, conversation ID: ${conversationId}`); this.logger.verbose(`Found conversation to: ${remoteJid}, conversation ID: ${conversationId}`);
let conversationExists: any; let conversationExists: conversation | boolean;
try { try {
conversationExists = await client.conversations.get({ conversationExists = await client.conversations.get({
accountId: this.provider.accountId, accountId: this.provider.accountId,
conversationId: conversationId, conversationId: conversationId,
}); });
this.logger.verbose( this.logger.verbose(`Conversation exists: ${JSON.stringify(conversationExists)}`);
`Conversation exists: ID: ${conversationExists.id} - Name: ${conversationExists.meta.sender.name} - Identifier: ${conversationExists.meta.sender.identifier}`,
);
} catch (error) { } catch (error) {
this.logger.error(`Error getting conversation: ${error}`); this.logger.error(`Error getting conversation: ${error}`);
conversationExists = false; conversationExists = false;
@ -723,7 +677,8 @@ export class ChatwootService {
return (await this.cache.get(cacheKey)) as number; return (await this.cache.get(cacheKey)) as number;
} }
const chatId = isGroup ? remoteJid : phoneNumber.split('@')[0].split(':')[0]; const isGroup = remoteJid.includes('@g.us');
const chatId = isGroup ? remoteJid : remoteJid.split('@')[0].split(':')[0];
let nameContact = !body.key.fromMe ? body.pushName : chatId; let nameContact = !body.key.fromMe ? body.pushName : chatId;
const filterInbox = await this.getInbox(instance); const filterInbox = await this.getInbox(instance);
if (!filterInbox) return null; if (!filterInbox) return null;
@ -731,22 +686,19 @@ export class ChatwootService {
if (isGroup) { if (isGroup) {
this.logger.verbose(`Processing group conversation`); this.logger.verbose(`Processing group conversation`);
const group = await this.waMonitor.waInstances[instance.instanceName].client.groupMetadata(chatId); const group = await this.waMonitor.waInstances[instance.instanceName].client.groupMetadata(chatId);
this.logger.verbose(`Group metadata: JID:${group.JID} - Subject:${group?.subject || group?.Name}`); this.logger.verbose(`Group metadata: ${JSON.stringify(group)}`);
const participantJid = isLid && !body.key.fromMe ? body.key.participantAlt : body.key.participant;
nameContact = `${group.subject} (GROUP)`; nameContact = `${group.subject} (GROUP)`;
const picture_url = await this.waMonitor.waInstances[instance.instanceName].profilePicture( const picture_url = await this.waMonitor.waInstances[instance.instanceName].profilePicture(
participantJid.split('@')[0], body.key.participant.split('@')[0],
); );
this.logger.verbose(`Participant profile picture URL: ${JSON.stringify(picture_url)}`); this.logger.verbose(`Participant profile picture URL: ${JSON.stringify(picture_url)}`);
const findParticipant = await this.findContact(instance, participantJid.split('@')[0]); const findParticipant = await this.findContact(instance, body.key.participant.split('@')[0]);
this.logger.verbose(`Found participant: ${JSON.stringify(findParticipant)}`);
if (findParticipant) { if (findParticipant) {
this.logger.verbose(
`Found participant: ID:${findParticipant.id} - Name: ${findParticipant.name} - identifier: ${findParticipant.identifier}`,
);
if (!findParticipant.name || findParticipant.name === chatId) { if (!findParticipant.name || findParticipant.name === chatId) {
await this.updateContact(instance, findParticipant.id, { await this.updateContact(instance, findParticipant.id, {
name: body.pushName, name: body.pushName,
@ -756,12 +708,12 @@ export class ChatwootService {
} else { } else {
await this.createContact( await this.createContact(
instance, instance,
participantJid.split('@')[0].split(':')[0], body.key.participant.split('@')[0],
filterInbox.id, filterInbox.id,
false, false,
body.pushName, body.pushName,
picture_url.profilePictureUrl || null, picture_url.profilePictureUrl || null,
participantJid, body.key.participant,
); );
} }
} }
@ -769,17 +721,23 @@ export class ChatwootService {
const picture_url = await this.waMonitor.waInstances[instance.instanceName].profilePicture(chatId); const picture_url = await this.waMonitor.waInstances[instance.instanceName].profilePicture(chatId);
this.logger.verbose(`Contact profile picture URL: ${JSON.stringify(picture_url)}`); this.logger.verbose(`Contact profile picture URL: ${JSON.stringify(picture_url)}`);
this.logger.verbose(`Searching contact for: ${chatId}`);
let contact = await this.findContact(instance, chatId); let contact = await this.findContact(instance, chatId);
if (contact) { if (contact) {
this.logger.verbose(`Found contact: ID:${contact.id} - Name:${contact.name}`); this.logger.verbose(`Found contact: ${JSON.stringify(contact)}`);
if (!body.key.fromMe) { if (!body.key.fromMe) {
const waProfilePictureFile = const waProfilePictureFile =
picture_url?.profilePictureUrl?.split('#')[0].split('?')[0].split('/').pop() || ''; picture_url?.profilePictureUrl?.split('#')[0].split('?')[0].split('/').pop() || '';
const chatwootProfilePictureFile = contact?.thumbnail?.split('#')[0].split('?')[0].split('/').pop() || ''; const chatwootProfilePictureFile = contact?.thumbnail?.split('#')[0].split('?')[0].split('/').pop() || '';
const pictureNeedsUpdate = waProfilePictureFile !== chatwootProfilePictureFile; const pictureNeedsUpdate = waProfilePictureFile !== chatwootProfilePictureFile;
const nameNeedsUpdate = !contact.name || contact.name === chatId; const nameNeedsUpdate =
!contact.name ||
contact.name === chatId ||
(`+${chatId}`.startsWith('+55')
? this.getNumbers(`+${chatId}`).some(
(v) => contact.name === v || contact.name === v.substring(3) || contact.name === v.substring(1),
)
: false);
this.logger.verbose(`Picture needs update: ${pictureNeedsUpdate}`); this.logger.verbose(`Picture needs update: ${pictureNeedsUpdate}`);
this.logger.verbose(`Name needs update: ${nameNeedsUpdate}`); this.logger.verbose(`Name needs update: ${nameNeedsUpdate}`);
if (pictureNeedsUpdate || nameNeedsUpdate) { if (pictureNeedsUpdate || nameNeedsUpdate) {
@ -798,7 +756,7 @@ export class ChatwootService {
isGroup, isGroup,
nameContact, nameContact,
picture_url.profilePictureUrl || null, picture_url.profilePictureUrl || null,
phoneNumber, remoteJid,
); );
} }
@ -814,6 +772,7 @@ export class ChatwootService {
accountId: this.provider.accountId, accountId: this.provider.accountId,
id: contactId, id: contactId,
})) as any; })) as any;
this.logger.verbose(`Contact conversations: ${JSON.stringify(contactConversations)}`);
if (!contactConversations || !contactConversations.payload) { if (!contactConversations || !contactConversations.payload) {
this.logger.error(`No conversations found or payload is undefined`); this.logger.error(`No conversations found or payload is undefined`);
@ -825,9 +784,7 @@ export class ChatwootService {
); );
if (inboxConversation) { if (inboxConversation) {
if (this.provider.reopenConversation) { if (this.provider.reopenConversation) {
this.logger.verbose( this.logger.verbose(`Found conversation in reopenConversation mode: ${JSON.stringify(inboxConversation)}`);
`Found conversation in reopenConversation mode: ID: ${inboxConversation.id} - Name: ${inboxConversation.meta.sender.name} - Identifier: ${inboxConversation.meta.sender.identifier}`,
);
if (inboxConversation && this.provider.conversationPending && inboxConversation.status !== 'open') { if (inboxConversation && this.provider.conversationPending && inboxConversation.status !== 'open') {
await client.conversations.toggleStatus({ await client.conversations.toggleStatus({
accountId: this.provider.accountId, accountId: this.provider.accountId,
@ -847,7 +804,7 @@ export class ChatwootService {
if (inboxConversation) { if (inboxConversation) {
this.logger.verbose(`Returning existing conversation ID: ${inboxConversation.id}`); this.logger.verbose(`Returning existing conversation ID: ${inboxConversation.id}`);
this.cache.set(cacheKey, inboxConversation.id, 1800); this.cache.set(cacheKey, inboxConversation.id, 8 * 3600);
return inboxConversation.id; return inboxConversation.id;
} }
} }
@ -861,6 +818,14 @@ export class ChatwootService {
data['status'] = 'pending'; data['status'] = 'pending';
} }
/*
Triple check after lock
Utilizei uma nova verificação para evitar que outra thread execute entre o terminio do while e o set lock
*/
if (await this.cache.has(cacheKey)) {
return (await this.cache.get(cacheKey)) as number;
}
const conversation = await client.conversations.create({ const conversation = await client.conversations.create({
accountId: this.provider.accountId, accountId: this.provider.accountId,
data, data,
@ -872,7 +837,7 @@ export class ChatwootService {
} }
this.logger.verbose(`New conversation created of ${remoteJid} with ID: ${conversation.id}`); this.logger.verbose(`New conversation created of ${remoteJid} with ID: ${conversation.id}`);
this.cache.set(cacheKey, conversation.id, 1800); this.cache.set(cacheKey, conversation.id, 8 * 3600);
return conversation.id; return conversation.id;
} finally { } finally {
await this.cache.delete(lockKey); await this.cache.delete(lockKey);
@ -1193,20 +1158,140 @@ export class ChatwootService {
public async sendAttachment(waInstance: any, number: string, media: any, caption?: string, options?: Options) { public async sendAttachment(waInstance: any, number: string, media: any, caption?: string, options?: Options) {
try { try {
const parsedMedia = path.parse(decodeURIComponent(media)); // Sempre baixar o arquivo do MinIO/S3 antes de enviar
let mimeType = mimeTypes.lookup(parsedMedia?.ext) || ''; // URLs presigned podem expirar, então convertemos para base64
let fileName = parsedMedia?.name + parsedMedia?.ext; let mediaBuffer: Buffer;
let mimeType: string;
let fileName: string;
if (!mimeType) { try {
const parts = media.split('/'); this.logger.verbose(`Downloading media from: ${media}`);
fileName = decodeURIComponent(parts[parts.length - 1]);
// Tentar fazer download do arquivo com autenticação do Chatwoot
// maxRedirects: 0 para não seguir redirects automaticamente
const response = await axios.get(media, { const response = await axios.get(media, {
responseType: 'arraybuffer', responseType: 'arraybuffer',
timeout: this.MEDIA_DOWNLOAD_TIMEOUT_MS,
headers: {
api_access_token: this.provider.token,
},
maxRedirects: 0, // Não seguir redirects automaticamente
validateStatus: (status) => status < 500, // Aceitar redirects (301, 302, 307)
}); });
mimeType = response.headers['content-type'];
this.logger.verbose(`Initial response status: ${response.status}`);
// Se for redirect, pegar a URL de destino e fazer novo request
if (response.status >= 300 && response.status < 400) {
const redirectUrl = response.headers.location;
this.logger.verbose(`Redirect to: ${redirectUrl}`);
if (redirectUrl) {
// Fazer novo request para a URL do S3/MinIO (sem autenticação, pois é presigned URL)
// IMPORTANTE: Chatwoot pode gerar a URL presigned ANTES de fazer upload
// Vamos tentar com retry usando exponential backoff se receber 404 (arquivo ainda não disponível)
this.logger.verbose('Downloading from S3/MinIO...');
let s3Response;
let retryCount = 0;
const maxRetries = this.S3_MAX_RETRIES;
const baseDelay = this.S3_BASE_DELAY_MS;
const maxDelay = this.S3_MAX_DELAY_MS;
while (retryCount <= maxRetries) {
s3Response = await axios.get(redirectUrl, {
responseType: 'arraybuffer',
timeout: this.MEDIA_DOWNLOAD_TIMEOUT_MS,
validateStatus: (status) => status < 500,
});
this.logger.verbose(
`S3 response status: ${s3Response.status}, size: ${s3Response.data?.byteLength || 0} bytes (attempt ${retryCount + 1}/${maxRetries + 1})`,
);
// Se não for 404, sair do loop
if (s3Response.status !== 404) {
break;
} }
// Se for 404 e ainda tem tentativas, aguardar com exponential backoff e tentar novamente
if (retryCount < maxRetries) {
// Exponential backoff com max delay (seguindo padrão do webhook controller)
const backoffDelay = Math.min(baseDelay * Math.pow(2, retryCount), maxDelay);
const errorBody = s3Response.data?.toString ? s3Response.data.toString('utf-8') : s3Response.data;
this.logger.warn(
`File not yet available in S3/MinIO (attempt ${retryCount + 1}/${maxRetries + 1}). Retrying in ${backoffDelay}ms with exponential backoff...`,
);
this.logger.verbose(`MinIO Response: ${errorBody}`);
await new Promise((resolve) => setTimeout(resolve, backoffDelay));
retryCount++;
} else {
// Última tentativa falhou
break;
}
}
// Após todas as tentativas, verificar o status final
if (s3Response.status === 404) {
const errorBody = s3Response.data?.toString ? s3Response.data.toString('utf-8') : s3Response.data;
this.logger.error(`File not found in S3/MinIO after ${maxRetries + 1} attempts. URL: ${redirectUrl}`);
this.logger.error(`MinIO Error Response: ${errorBody}`);
throw new Error(
'File not found in S3/MinIO (404). The file may have been deleted, the URL is incorrect, or Chatwoot has not finished uploading yet.',
);
}
if (s3Response.status === 403) {
this.logger.error(`Access denied to S3/MinIO. URL may have expired: ${redirectUrl}`);
throw new Error(
'Access denied to S3/MinIO (403). Presigned URL may have expired. Check S3_PRESIGNED_EXPIRATION setting.',
);
}
if (s3Response.status >= 400) {
this.logger.error(`S3/MinIO error ${s3Response.status}: ${s3Response.statusText}`);
throw new Error(`S3/MinIO error ${s3Response.status}: ${s3Response.statusText}`);
}
mediaBuffer = Buffer.from(s3Response.data);
mimeType = s3Response.headers['content-type'] || 'application/octet-stream';
this.logger.verbose(`Downloaded ${mediaBuffer.length} bytes from S3, type: ${mimeType}`);
} else {
this.logger.error('Redirect response without Location header');
throw new Error('Redirect without Location header');
}
} else if (response.status === 404) {
this.logger.error(`File not found (404) at: ${media}`);
throw new Error('File not found (404). The attachment may not exist in Chatwoot storage.');
} else if (response.status >= 400) {
this.logger.error(`HTTP ${response.status}: ${response.statusText} for URL: ${media}`);
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
} else {
// Download direto sem redirect
mediaBuffer = Buffer.from(response.data);
mimeType = response.headers['content-type'] || 'application/octet-stream';
this.logger.verbose(`Downloaded ${mediaBuffer.length} bytes directly, type: ${mimeType}`);
}
// Extrair nome do arquivo da URL ou usar o content-disposition
const parsedMedia = path.parse(decodeURIComponent(media));
if (parsedMedia?.name && parsedMedia?.ext) {
fileName = parsedMedia.name + parsedMedia.ext;
} else {
const parts = media.split('/');
fileName = decodeURIComponent(parts[parts.length - 1].split('?')[0]);
}
this.logger.verbose(`File name: ${fileName}, size: ${mediaBuffer.length} bytes`);
} catch (downloadError) {
this.logger.error('[MEDIA DOWNLOAD] ❌ Error downloading media from: ' + media);
this.logger.error(`[MEDIA DOWNLOAD] Error message: ${downloadError.message}`);
this.logger.error(`[MEDIA DOWNLOAD] Error stack: ${downloadError.stack}`);
this.logger.error(`[MEDIA DOWNLOAD] Full error: ${JSON.stringify(downloadError, null, 2)}`);
throw new Error(`Failed to download media: ${downloadError.message}`);
}
// Determinar o tipo de mídia pelo mimetype
let type = 'document'; let type = 'document';
switch (mimeType.split('/')[0]) { switch (mimeType.split('/')[0]) {
@ -1224,11 +1309,13 @@ export class ChatwootService {
break; break;
} }
// Para áudio, usar base64 com data URI
if (type === 'audio') { if (type === 'audio') {
const base64Audio = `data:${mimeType};base64,${mediaBuffer.toString('base64')}`;
const data: SendAudioDto = { const data: SendAudioDto = {
number: number, number: number,
audio: media, audio: base64Audio,
delay: Math.floor(Math.random() * (2000 - 500 + 1)) + 500, delay: 1200,
quoted: options?.quoted, quoted: options?.quoted,
}; };
@ -1239,8 +1326,12 @@ export class ChatwootService {
return messageSent; return messageSent;
} }
// Para outros tipos, converter para base64 puro (sem prefixo data URI)
const base64Media = mediaBuffer.toString('base64');
const documentExtensions = ['.gif', '.svg', '.tiff', '.tif', '.dxf', '.dwg']; const documentExtensions = ['.gif', '.svg', '.tiff', '.tif', '.dxf', '.dwg'];
if (type === 'image' && parsedMedia && documentExtensions.includes(parsedMedia?.ext)) { const parsedExt = path.parse(fileName)?.ext;
if (type === 'image' && parsedExt && documentExtensions.includes(parsedExt)) {
type = 'document'; type = 'document';
} }
@ -1248,7 +1339,7 @@ export class ChatwootService {
number: number, number: number,
mediatype: type as any, mediatype: type as any,
fileName: fileName, fileName: fileName,
media: media, media: base64Media, // Base64 puro, sem prefixo
delay: 1200, delay: 1200,
quoted: options?.quoted, quoted: options?.quoted,
}; };
@ -1304,9 +1395,87 @@ export class ChatwootService {
}); });
} }
/**
* Processa deleção de mensagem em background
* Método assíncrono chamado via setImmediate para não bloquear resposta do webhook
*/
private async processDeletion(instance: InstanceDto, body: any, deleteLockKey: string) {
this.logger.warn(`[DELETE] 🗑️ Processing deletion - messageId: ${body.id}`);
const waInstance = this.waMonitor.waInstances[instance.instanceName];
// Buscar TODAS as mensagens com esse chatwootMessageId (pode ser múltiplos anexos)
const messages = await this.prismaRepository.message.findMany({
where: {
chatwootMessageId: body.id,
instanceId: instance.instanceId,
},
});
if (messages && messages.length > 0) {
this.logger.warn(`[DELETE] Found ${messages.length} message(s) to delete from Chatwoot message ${body.id}`);
this.logger.verbose(`[DELETE] Messages keys: ${messages.map((m) => (m.key as any)?.id).join(', ')}`);
// Deletar cada mensagem no WhatsApp
for (const message of messages) {
const key = message.key as WAMessageKey;
this.logger.warn(
`[DELETE] Attempting to delete WhatsApp message - keyId: ${key?.id}, remoteJid: ${key?.remoteJid}`,
);
try {
await waInstance?.client.sendMessage(key.remoteJid, { delete: key });
this.logger.warn(`[DELETE] ✅ Message ${key.id} deleted in WhatsApp successfully`);
} catch (error) {
this.logger.error(`[DELETE] ❌ Error deleting message ${key.id} in WhatsApp: ${error}`);
this.logger.error(`[DELETE] Error details: ${JSON.stringify(error, null, 2)}`);
}
}
// Remover todas as mensagens do banco de dados
await this.prismaRepository.message.deleteMany({
where: {
instanceId: instance.instanceId,
chatwootMessageId: body.id,
},
});
this.logger.warn(`[DELETE] ✅ SUCCESS: ${messages.length} message(s) deleted from WhatsApp and database`);
} else {
// Mensagem não encontrada - pode ser uma mensagem antiga que foi substituída por edição
this.logger.warn(`[DELETE] ⚠️ WARNING: Message not found in DB - chatwootMessageId: ${body.id}`);
}
// Liberar lock após processar
await this.cache.delete(deleteLockKey);
}
public async receiveWebhook(instance: InstanceDto, body: any) { public async receiveWebhook(instance: InstanceDto, body: any) {
try { try {
await new Promise((resolve) => setTimeout(resolve, 500)); // IMPORTANTE: Verificar lock de deleção ANTES do delay inicial
// para evitar race condition com webhooks duplicados
let isDeletionEvent = false;
if (body.event === 'message_updated' && body.content_attributes?.deleted) {
isDeletionEvent = true;
const deleteLockKey = `${instance.instanceName}:deleteMessage-${body.id}`;
// Verificar se já está processando esta deleção
if (await this.cache.has(deleteLockKey)) {
this.logger.warn(`[DELETE] ⏭️ SKIPPING: Deletion already in progress for messageId: ${body.id}`);
return { message: 'already_processing' };
}
// Adquirir lock IMEDIATAMENTE por 30 segundos
await this.cache.set(deleteLockKey, true, 30);
this.logger.warn(
`[WEBHOOK-DELETE] Event: ${body.event}, messageId: ${body.id}, conversation: ${body.conversation?.id}`,
);
}
// Para deleções, processar IMEDIATAMENTE (sem delay)
// Para outros eventos, aguardar delay inicial
if (!isDeletionEvent) {
await new Promise((resolve) => setTimeout(resolve, this.WEBHOOK_INITIAL_DELAY_MS));
}
const client = await this.clientCw(instance); const client = await this.clientCw(instance);
@ -1325,6 +1494,39 @@ export class ChatwootService {
this.cache.delete(keyToDelete); this.cache.delete(keyToDelete);
} }
// Log para debug de mensagens deletadas
if (body.event === 'message_updated') {
this.logger.verbose(
`Message updated event - deleted: ${body.content_attributes?.deleted}, messageId: ${body.id}`,
);
}
// Processar deleção de mensagem ANTES das outras validações
if (body.event === 'message_updated' && body.content_attributes?.deleted) {
// Lock já foi adquirido no início do método (antes do delay)
const deleteLockKey = `${instance.instanceName}:deleteMessage-${body.id}`;
// ESTRATÉGIA: Processar em background e responder IMEDIATAMENTE
// Isso evita timeout do Chatwoot (5s) quando há muitas imagens (> 5s de processamento)
this.logger.warn(`[DELETE] 🚀 Starting background deletion - messageId: ${body.id}`);
// Executar em background (sem await) - não bloqueia resposta do webhook
setImmediate(async () => {
try {
await this.processDeletion(instance, body, deleteLockKey);
} catch (error) {
this.logger.error(`[DELETE] ❌ Background deletion failed for messageId ${body.id}: ${error}`);
}
});
// RESPONDER IMEDIATAMENTE ao Chatwoot (< 50ms)
return {
message: 'deletion_accepted',
messageId: body.id,
note: 'Deletion is being processed in background',
};
}
if ( if (
!body?.conversation || !body?.conversation ||
body.private || body.private ||
@ -1346,7 +1548,6 @@ export class ChatwootService {
const senderName = body?.conversation?.messages[0]?.sender?.available_name || body?.sender?.name; const senderName = body?.conversation?.messages[0]?.sender?.available_name || body?.sender?.name;
const waInstance = this.waMonitor.waInstances[instance.instanceName]; const waInstance = this.waMonitor.waInstances[instance.instanceName];
instance.instanceId = waInstance.instanceId;
if (body.event === 'message_updated' && body.content_attributes?.deleted) { if (body.event === 'message_updated' && body.content_attributes?.deleted) {
const message = await this.prismaRepository.message.findFirst({ const message = await this.prismaRepository.message.findFirst({
@ -1442,7 +1643,10 @@ export class ChatwootService {
} }
if (body.message_type === 'outgoing' && body?.conversation?.messages?.length && chatId !== '123456') { if (body.message_type === 'outgoing' && body?.conversation?.messages?.length && chatId !== '123456') {
if (body?.conversation?.messages[0]?.source_id?.substring(0, 5) === 'WAID:') { if (
body?.conversation?.messages[0]?.source_id?.substring(0, 5) === 'WAID:' &&
body?.conversation?.messages[0]?.id === body?.id
) {
return { message: 'bot' }; return { message: 'bot' };
} }
@ -1466,6 +1670,8 @@ export class ChatwootService {
for (const message of body.conversation.messages) { for (const message of body.conversation.messages) {
if (message.attachments && message.attachments.length > 0) { if (message.attachments && message.attachments.length > 0) {
// Processa anexos de forma assíncrona para não bloquear o webhook
const processAttachments = async () => {
for (const attachment of message.attachments) { for (const attachment of message.attachments) {
if (!messageReceived) { if (!messageReceived) {
formatText = null; formatText = null;
@ -1475,6 +1681,7 @@ export class ChatwootService {
quoted: await this.getQuotedMessage(body, instance), quoted: await this.getQuotedMessage(body, instance),
}; };
try {
const messageSent = await this.sendAttachment( const messageSent = await this.sendAttachment(
waInstance, waInstance,
chatId, chatId,
@ -1482,13 +1689,16 @@ export class ChatwootService {
formatText, formatText,
options, options,
); );
if (!messageSent && body.conversation?.id) { if (!messageSent && body.conversation?.id) {
this.onSendMessageError(instance, body.conversation?.id); this.onSendMessageError(instance, body.conversation?.id);
} }
if (messageSent) {
await this.updateChatwootMessageId( await this.updateChatwootMessageId(
{ {
...messageSent, ...messageSent,
owner: instance.instanceName,
}, },
{ {
messageId: body.id, messageId: body.id,
@ -1499,11 +1709,24 @@ export class ChatwootService {
instance, instance,
); );
} }
} catch (error) {
this.logger.error(error);
if (body.conversation?.id) {
this.onSendMessageError(instance, body.conversation?.id, error);
}
}
}
};
// Executa em background sem bloquear
processAttachments().catch((error) => {
this.logger.error(error);
});
} else { } else {
const data: SendTextDto = { const data: SendTextDto = {
number: chatId, number: chatId,
text: formatText, text: formatText,
delay: Math.floor(Math.random() * (2000 - 500 + 1)) + 500, delay: 1200,
quoted: await this.getQuotedMessage(body, instance), quoted: await this.getQuotedMessage(body, instance),
}; };
@ -1521,9 +1744,7 @@ export class ChatwootService {
} }
await this.updateChatwootMessageId( await this.updateChatwootMessageId(
{ messageSent, // Já tem instanceId
...messageSent,
},
{ {
messageId: body.id, messageId: body.id,
inboxId: body.inbox?.id, inboxId: body.inbox?.id,
@ -1590,7 +1811,7 @@ export class ChatwootService {
const data: SendTextDto = { const data: SendTextDto = {
number: chatId, number: chatId,
text: body.content.replace(/\\\r\n|\\\n|\n/g, '\n'), text: body.content.replace(/\\\r\n|\\\n|\n/g, '\n'),
delay: Math.floor(Math.random() * (2000 - 500 + 1)) + 500, delay: 1200,
}; };
sendTelemetry('/message/sendText'); sendTelemetry('/message/sendText');
@ -1614,6 +1835,55 @@ export class ChatwootService {
const key = message.key as WAMessageKey; const key = message.key as WAMessageKey;
if (!chatwootMessageIds.messageId || !key?.id) { if (!chatwootMessageIds.messageId || !key?.id) {
this.logger.verbose(
`Skipping updateChatwootMessageId - messageId: ${chatwootMessageIds.messageId}, keyId: ${key?.id}`,
);
return;
}
// Use instanceId from message or fallback to instance
const instanceId = message.instanceId || instance.instanceId;
this.logger.verbose(
`Updating message with chatwootMessageId: ${chatwootMessageIds.messageId}, keyId: ${key.id}, instanceId: ${instanceId}`,
);
// Verifica se a mensagem existe antes de atualizar usando polling com exponential backoff
let retries = 0;
const maxRetries = this.DB_POLLING_MAX_RETRIES;
const baseDelay = this.DB_POLLING_BASE_DELAY_MS;
const maxDelay = this.DB_POLLING_MAX_DELAY_MS;
let messageExists = false;
while (retries < maxRetries && !messageExists) {
const existingMessage = await this.prismaRepository.message.findFirst({
where: {
instanceId: instanceId,
key: {
path: ['id'],
equals: key.id,
},
},
});
if (existingMessage) {
messageExists = true;
this.logger.verbose(`Message found in database after ${retries} retries`);
} else {
retries++;
if (retries < maxRetries) {
// Exponential backoff com max delay (seguindo padrão do sistema)
const backoffDelay = Math.min(baseDelay * Math.pow(2, retries - 1), maxDelay);
this.logger.verbose(`Message not found, retry ${retries}/${maxRetries} in ${backoffDelay}ms`);
await new Promise((resolve) => setTimeout(resolve, backoffDelay));
} else {
this.logger.verbose(`Message not found after ${retries} attempts`);
}
}
}
if (!messageExists) {
this.logger.warn(`Message not found in database after ${maxRetries} retries, keyId: ${key.id}`);
return; return;
} }
@ -1626,18 +1896,14 @@ export class ChatwootService {
"chatwootInboxId" = ${chatwootMessageIds.inboxId}, "chatwootInboxId" = ${chatwootMessageIds.inboxId},
"chatwootContactInboxSourceId" = ${chatwootMessageIds.contactInboxSourceId}, "chatwootContactInboxSourceId" = ${chatwootMessageIds.contactInboxSourceId},
"chatwootIsRead" = ${chatwootMessageIds.isRead || false} "chatwootIsRead" = ${chatwootMessageIds.isRead || false}
WHERE "instanceId" = ${instance.instanceId} WHERE "instanceId" = ${instanceId}
AND "key"->>'id' = ${key.id} AND "key"->>'id' = ${key.id}
`; `;
this.logger.verbose(`Update result: ${result} rows affected`); this.logger.verbose(`Update result: ${result} rows affected`);
if (this.isImportHistoryAvailable()) { if (this.isImportHistoryAvailable()) {
try { chatwootImport.updateMessageSourceID(chatwootMessageIds.messageId, key.id);
await chatwootImport.updateMessageSourceID(chatwootMessageIds.messageId, key.id);
} catch (error) {
this.logger.error(`Error updating Chatwoot message source ID: ${error}`);
}
} }
} }
@ -1686,12 +1952,11 @@ export class ChatwootService {
}); });
const key = message?.key as WAMessageKey; const key = message?.key as WAMessageKey;
const messageContent = message?.message as WAMessageContent;
if (messageContent && key?.id) { if (message && key?.id) {
return { return {
key: key, key: message.key as proto.IMessageKey,
message: messageContent, message: message.message as proto.IMessage,
}; };
} }
} }
@ -1717,10 +1982,6 @@ export class ChatwootService {
return result; return result;
} }
private isInteractiveButtonMessage(messageType: string, message: any) {
return messageType === 'interactiveMessage' && message.interactiveMessage?.nativeFlowMessage?.buttons?.length > 0;
}
private getAdsMessage(msg: any) { private getAdsMessage(msg: any) {
interface AdsMessage { interface AdsMessage {
title: string; title: string;
@ -2039,9 +2300,8 @@ export class ChatwootService {
const adsMessage = this.getAdsMessage(body); const adsMessage = this.getAdsMessage(body);
const reactionMessage = this.getReactionMessage(body.message); const reactionMessage = this.getReactionMessage(body.message);
const isInteractiveButtonMessage = this.isInteractiveButtonMessage(body.messageType, body.message);
if (!bodyMessage && !isMedia && !reactionMessage && !isInteractiveButtonMessage) { if (!bodyMessage && !isMedia && !reactionMessage) {
this.logger.warn('no body message found'); this.logger.warn('no body message found');
return; return;
} }
@ -2086,20 +2346,23 @@ export class ChatwootService {
if (body.key.remoteJid.includes('@g.us')) { if (body.key.remoteJid.includes('@g.us')) {
const participantName = body.pushName; const participantName = body.pushName;
const rawPhoneNumber = const rawPhoneNumber = body.key.participant.split('@')[0];
body.key.addressingMode === 'lid' && !body.key.fromMe && body.key.participantAlt const phoneMatch = rawPhoneNumber.match(/^(\d{2})(\d{2})(\d{4})(\d{4})$/);
? body.key.participantAlt.split('@')[0].split(':')[0]
: body.key.participant.split('@')[0].split(':')[0]; let formattedPhoneNumber: string;
const formattedPhoneNumber = parsePhoneNumberFromString(`+${rawPhoneNumber}`).formatInternational();
if (phoneMatch) {
formattedPhoneNumber = `+${phoneMatch[1]} (${phoneMatch[2]}) ${phoneMatch[3]}-${phoneMatch[4]}`;
} else {
formattedPhoneNumber = `+${rawPhoneNumber}`;
}
let content: string; let content: string;
if (!body.key.fromMe) { if (!body.key.fromMe) {
content = bodyMessage content = `**${formattedPhoneNumber} - ${participantName}:**\n\n${bodyMessage}`;
? `**${formattedPhoneNumber} - ${participantName}:**\n\n${bodyMessage}`
: `**${formattedPhoneNumber} - ${participantName}:**`;
} else { } else {
content = bodyMessage || ''; content = `${bodyMessage}`;
} }
const send = await this.sendData( const send = await this.sendData(
@ -2166,50 +2429,6 @@ export class ChatwootService {
return; return;
} }
if (isInteractiveButtonMessage) {
const buttons = body.message.interactiveMessage.nativeFlowMessage.buttons;
this.logger.info('is Interactive Button Message: ' + JSON.stringify(buttons));
for (const button of buttons) {
const buttonParams = JSON.parse(button.buttonParamsJson);
const paymentSettings = buttonParams.payment_settings;
if (button.name === 'payment_info' && paymentSettings[0].type === 'pix_static_code') {
const pixSettings = paymentSettings[0].pix_static_code;
const pixKeyType = (() => {
switch (pixSettings.key_type) {
case 'EVP':
return 'Chave Aleatória';
case 'EMAIL':
return 'E-mail';
case 'PHONE':
return 'Telefone';
default:
return pixSettings.key_type;
}
})();
const pixKey = pixSettings.key_type === 'PHONE' ? pixSettings.key.replace('+55', '') : pixSettings.key;
const content = `*${pixSettings.merchant_name}*\nChave PIX: ${pixKey} (${pixKeyType})`;
const send = await this.createMessage(
instance,
getConversation,
content,
messageType,
false,
[],
body,
'WAID:' + body.key.id,
quotedMsg,
);
if (!send) this.logger.warn('message not sent');
} else {
this.logger.warn('Interactive Button Message not mapped');
}
}
return;
}
const isAdsMessage = (adsMessage && adsMessage.title) || adsMessage.body || adsMessage.thumbnailUrl; const isAdsMessage = (adsMessage && adsMessage.title) || adsMessage.body || adsMessage.thumbnailUrl;
if (isAdsMessage) { if (isAdsMessage) {
const imgBuffer = await axios.get(adsMessage.thumbnailUrl, { responseType: 'arraybuffer' }); const imgBuffer = await axios.get(adsMessage.thumbnailUrl, { responseType: 'arraybuffer' });
@ -2268,11 +2487,16 @@ export class ChatwootService {
if (body.key.remoteJid.includes('@g.us')) { if (body.key.remoteJid.includes('@g.us')) {
const participantName = body.pushName; const participantName = body.pushName;
const rawPhoneNumber = const rawPhoneNumber = body.key.participant.split('@')[0];
body.key.addressingMode === 'lid' && !body.key.fromMe && body.key.participantAlt const phoneMatch = rawPhoneNumber.match(/^(\d{2})(\d{2})(\d{4})(\d{4})$/);
? body.key.participantAlt.split('@')[0].split(':')[0]
: body.key.participant.split('@')[0].split(':')[0]; let formattedPhoneNumber: string;
const formattedPhoneNumber = parsePhoneNumberFromString(`+${rawPhoneNumber}`).formatInternational();
if (phoneMatch) {
formattedPhoneNumber = `+${phoneMatch[1]} (${phoneMatch[2]}) ${phoneMatch[3]}-${phoneMatch[4]}`;
} else {
formattedPhoneNumber = `+${rawPhoneNumber}`;
}
let content: string; let content: string;
@ -2354,21 +2578,8 @@ export class ChatwootService {
} }
if (event === 'messages.edit' || event === 'send.message.update') { if (event === 'messages.edit' || event === 'send.message.update') {
const editedMessageContentRaw = const editedMessageContent =
body?.editedMessage?.conversation ?? body?.editedMessage?.conversation || body?.editedMessage?.extendedTextMessage?.text;
body?.editedMessage?.extendedTextMessage?.text ??
body?.editedMessage?.imageMessage?.caption ??
body?.editedMessage?.videoMessage?.caption ??
body?.editedMessage?.documentMessage?.caption ??
(typeof body?.text === 'string' ? body.text : undefined);
const editedMessageContent = (editedMessageContentRaw ?? '').trim();
if (!editedMessageContent) {
this.logger.info('[CW.EDIT] Conteúdo vazio — ignorando (DELETE tratará se for revoke).');
return;
}
const message = await this.getMessageByKeyId(instance, body?.key?.id); const message = await this.getMessageByKeyId(instance, body?.key?.id);
if (!message) { if (!message) {
@ -2435,7 +2646,7 @@ export class ChatwootService {
const url = const url =
`/public/api/v1/inboxes/${inbox.inbox_identifier}/contacts/${sourceId}` + `/public/api/v1/inboxes/${inbox.inbox_identifier}/contacts/${sourceId}` +
`/conversations/${conversationId}/update_last_seen`; `/conversations/${conversationId}/update_last_seen`;
await chatwootRequest(this.getClientCwConfig(), { chatwootRequest(this.getClientCwConfig(), {
method: 'POST', method: 'POST',
url: url, url: url,
}); });
@ -2477,7 +2688,7 @@ export class ChatwootService {
chatwootImport.clearAll(instance); chatwootImport.clearAll(instance);
} }
// Se não foi via QR code, verifica o throttling. // Se não foi via QR code, verifica o throttling.
else if (timeSinceLastNotification >= 30000) { else if (timeSinceLastNotification >= MIN_CONNECTION_NOTIFICATION_INTERVAL_MS) {
const msgConnection = i18next.t('cw.inbox.connected'); const msgConnection = i18next.t('cw.inbox.connected');
await this.createBotMessage(instance, msgConnection, 'incoming'); await this.createBotMessage(instance, msgConnection, 'incoming');
waInstance.lastConnectionNotification = now; waInstance.lastConnectionNotification = now;
@ -2527,13 +2738,7 @@ export class ChatwootService {
} }
} }
public normalizeJidIdentifier(remoteJid: string) { public getNumberFromRemoteJid(remoteJid: string) {
if (!remoteJid) {
return '';
}
if (remoteJid.includes('@lid')) {
return remoteJid;
}
return remoteJid.replace(/:\d+/, '').split('@')[0]; return remoteJid.replace(/:\d+/, '').split('@')[0];
} }

View File

@ -137,7 +137,7 @@ class ChatwootImport {
DO UPDATE SET DO UPDATE SET
name = EXCLUDED.name, name = EXCLUDED.name,
phone_number = EXCLUDED.phone_number, phone_number = EXCLUDED.phone_number,
updated_at = NOW()`; identifier = EXCLUDED.identifier`;
totalContactsImported += (await pgClient.query(sqlInsert, bindInsert))?.rowCount ?? 0; totalContactsImported += (await pgClient.query(sqlInsert, bindInsert))?.rowCount ?? 0;

View File

@ -51,7 +51,6 @@ export class N8nService extends BaseChatbotService<N8n, N8nSetting> {
pushName: pushName, pushName: pushName,
keyId: msg?.key?.id, keyId: msg?.key?.id,
fromMe: msg?.key?.fromMe, fromMe: msg?.key?.fromMe,
quotedMessage: msg?.contextInfo?.quotedMessage,
instanceName: instance.instanceName, instanceName: instance.instanceName,
serverUrl: this.configService.get<HttpServer>('SERVER').URL, serverUrl: this.configService.get<HttpServer>('SERVER').URL,
apiKey: instance.token, apiKey: instance.token,

View File

@ -327,7 +327,7 @@ export class TypebotService extends BaseChatbotService<TypebotModel, any> {
if (message.type === 'image') { if (message.type === 'image') {
await instance.mediaMessage( await instance.mediaMessage(
{ {
number: session.remoteJid, number: session.remoteJid.split('@')[0],
delay: settings?.delayMessage || 1000, delay: settings?.delayMessage || 1000,
mediatype: 'image', mediatype: 'image',
media: message.content.url, media: message.content.url,
@ -342,7 +342,7 @@ export class TypebotService extends BaseChatbotService<TypebotModel, any> {
if (message.type === 'video') { if (message.type === 'video') {
await instance.mediaMessage( await instance.mediaMessage(
{ {
number: session.remoteJid, number: session.remoteJid.split('@')[0],
delay: settings?.delayMessage || 1000, delay: settings?.delayMessage || 1000,
mediatype: 'video', mediatype: 'video',
media: message.content.url, media: message.content.url,
@ -357,7 +357,7 @@ export class TypebotService extends BaseChatbotService<TypebotModel, any> {
if (message.type === 'audio') { if (message.type === 'audio') {
await instance.audioWhatsapp( await instance.audioWhatsapp(
{ {
number: session.remoteJid, number: session.remoteJid.split('@')[0],
delay: settings?.delayMessage || 1000, delay: settings?.delayMessage || 1000,
encoding: true, encoding: true,
audio: message.content.url, audio: message.content.url,
@ -441,7 +441,7 @@ export class TypebotService extends BaseChatbotService<TypebotModel, any> {
*/ */
private async processListMessage(instance: any, formattedText: string, remoteJid: string) { private async processListMessage(instance: any, formattedText: string, remoteJid: string) {
const listJson = { const listJson = {
number: remoteJid, number: remoteJid.split('@')[0],
title: '', title: '',
description: '', description: '',
buttonText: '', buttonText: '',
@ -490,7 +490,7 @@ export class TypebotService extends BaseChatbotService<TypebotModel, any> {
*/ */
private async processButtonMessage(instance: any, formattedText: string, remoteJid: string) { private async processButtonMessage(instance: any, formattedText: string, remoteJid: string) {
const buttonJson = { const buttonJson = {
number: remoteJid, number: remoteJid.split('@')[0],
thumbnailUrl: undefined, thumbnailUrl: undefined,
title: '', title: '',
description: '', description: '',

View File

@ -14,24 +14,12 @@ export type EmitData = {
apiKey?: string; apiKey?: string;
local?: boolean; local?: boolean;
integration?: string[]; integration?: string[];
extra?: Record<string, any>;
}; };
export interface EventControllerInterface { export interface EventControllerInterface {
set(instanceName: string, data: any): Promise<any>; set(instanceName: string, data: any): Promise<any>;
get(instanceName: string): Promise<any>; get(instanceName: string): Promise<any>;
emit({ emit({ instanceName, origin, event, data, serverUrl, dateTime, sender, apiKey, local }: EmitData): Promise<void>;
instanceName,
origin,
event,
data,
serverUrl,
dateTime,
sender,
apiKey,
local,
extra,
}: EmitData): Promise<void>;
} }
export class EventController { export class EventController {

View File

@ -123,7 +123,6 @@ export class EventManager {
apiKey?: string; apiKey?: string;
local?: boolean; local?: boolean;
integration?: string[]; integration?: string[];
extra?: Record<string, any>;
}): Promise<void> { }): Promise<void> {
await this.websocket.emit(eventData); await this.websocket.emit(eventData);
await this.rabbitmq.emit(eventData); await this.rabbitmq.emit(eventData);

View File

@ -262,7 +262,6 @@ export class KafkaController extends EventController implements EventControllerI
sender, sender,
apiKey, apiKey,
integration, integration,
extra,
}: EmitData): Promise<void> { }: EmitData): Promise<void> {
if (integration && !integration.includes('kafka')) { if (integration && !integration.includes('kafka')) {
return; return;
@ -285,7 +284,6 @@ export class KafkaController extends EventController implements EventControllerI
const logEnabled = configService.get<Log>('LOG').LEVEL.includes('WEBHOOKS'); const logEnabled = configService.get<Log>('LOG').LEVEL.includes('WEBHOOKS');
const message = { const message = {
...(extra ?? {}),
event, event,
instance: instanceName, instance: instanceName,
data, data,

View File

@ -47,7 +47,6 @@ export class NatsController extends EventController implements EventControllerIn
sender, sender,
apiKey, apiKey,
integration, integration,
extra,
}: EmitData): Promise<void> { }: EmitData): Promise<void> {
if (integration && !integration.includes('nats')) { if (integration && !integration.includes('nats')) {
return; return;
@ -66,7 +65,6 @@ export class NatsController extends EventController implements EventControllerIn
const logEnabled = configService.get<Log>('LOG').LEVEL.includes('WEBHOOKS'); const logEnabled = configService.get<Log>('LOG').LEVEL.includes('WEBHOOKS');
const message = { const message = {
...(extra ?? {}),
event, event,
instance: instanceName, instance: instanceName,
data, data,

View File

@ -121,7 +121,6 @@ export class PusherController extends EventController implements EventController
apiKey, apiKey,
local, local,
integration, integration,
extra,
}: EmitData): Promise<void> { }: EmitData): Promise<void> {
if (integration && !integration.includes('pusher')) { if (integration && !integration.includes('pusher')) {
return; return;
@ -134,7 +133,6 @@ export class PusherController extends EventController implements EventController
const enabledLog = configService.get<Log>('LOG').LEVEL.includes('WEBHOOKS'); const enabledLog = configService.get<Log>('LOG').LEVEL.includes('WEBHOOKS');
const eventName = event.replace(/_/g, '.').toLowerCase(); const eventName = event.replace(/_/g, '.').toLowerCase();
const pusherData = { const pusherData = {
...(extra ?? {}),
event, event,
instance: instanceName, instance: instanceName,
data, data,

View File

@ -209,7 +209,6 @@ export class RabbitmqController extends EventController implements EventControll
sender, sender,
apiKey, apiKey,
integration, integration,
extra,
}: EmitData): Promise<void> { }: EmitData): Promise<void> {
if (integration && !integration.includes('rabbitmq')) { if (integration && !integration.includes('rabbitmq')) {
return; return;
@ -234,7 +233,6 @@ export class RabbitmqController extends EventController implements EventControll
const logEnabled = configService.get<Log>('LOG').LEVEL.includes('WEBHOOKS'); const logEnabled = configService.get<Log>('LOG').LEVEL.includes('WEBHOOKS');
const message = { const message = {
...(extra ?? {}),
event, event,
instance: instanceName, instance: instanceName,
data, data,

View File

@ -93,7 +93,6 @@ export class SqsController extends EventController implements EventControllerInt
sender, sender,
apiKey, apiKey,
integration, integration,
extra,
}: EmitData): Promise<void> { }: EmitData): Promise<void> {
if (integration && !integration.includes('sqs')) { if (integration && !integration.includes('sqs')) {
return; return;
@ -129,7 +128,6 @@ export class SqsController extends EventController implements EventControllerInt
const sqsUrl = `https://sqs.${sqsConfig.REGION}.amazonaws.com/${sqsConfig.ACCOUNT_ID}/${queueName}`; const sqsUrl = `https://sqs.${sqsConfig.REGION}.amazonaws.com/${sqsConfig.ACCOUNT_ID}/${queueName}`;
const message = { const message = {
...(extra ?? {}),
event, event,
instance: instanceName, instance: instanceName,
dataType: 'json', dataType: 'json',

View File

@ -65,7 +65,6 @@ export class WebhookController extends EventController implements EventControlle
apiKey, apiKey,
local, local,
integration, integration,
extra,
}: EmitData): Promise<void> { }: EmitData): Promise<void> {
if (integration && !integration.includes('webhook')) { if (integration && !integration.includes('webhook')) {
return; return;
@ -91,7 +90,6 @@ export class WebhookController extends EventController implements EventControlle
const regex = /^(https?:\/\/)/; const regex = /^(https?:\/\/)/;
const webhookData = { const webhookData = {
...(extra ?? {}),
event, event,
instance: instanceName, instance: instanceName,
data, data,

View File

@ -33,10 +33,7 @@ export class WebsocketController extends EventController implements EventControl
const { remoteAddress } = req.socket; const { remoteAddress } = req.socket;
const websocketConfig = configService.get<Websocket>('WEBSOCKET'); const websocketConfig = configService.get<Websocket>('WEBSOCKET');
const allowedHosts = websocketConfig.ALLOWED_HOSTS || '127.0.0.1,::1,::ffff:127.0.0.1'; const allowedHosts = websocketConfig.ALLOWED_HOSTS || '127.0.0.1,::1,::ffff:127.0.0.1';
const allowAllHosts = allowedHosts.trim() === '*'; const isAllowedHost = allowedHosts
const isAllowedHost =
allowAllHosts ||
allowedHosts
.split(',') .split(',')
.map((h) => h.trim()) .map((h) => h.trim())
.includes(remoteAddress); .includes(remoteAddress);
@ -118,7 +115,6 @@ export class WebsocketController extends EventController implements EventControl
sender, sender,
apiKey, apiKey,
integration, integration,
extra,
}: EmitData): Promise<void> { }: EmitData): Promise<void> {
if (integration && !integration.includes('websocket')) { if (integration && !integration.includes('websocket')) {
return; return;
@ -131,7 +127,6 @@ export class WebsocketController extends EventController implements EventControl
const configEv = event.replace(/[.-]/gm, '_').toUpperCase(); const configEv = event.replace(/[.-]/gm, '_').toUpperCase();
const logEnabled = configService.get<Log>('LOG').LEVEL.includes('WEBSOCKET'); const logEnabled = configService.get<Log>('LOG').LEVEL.includes('WEBSOCKET');
const message = { const message = {
...(extra ?? {}),
event, event,
instance: instanceName, instance: instanceName,
data, data,

View File

@ -48,14 +48,9 @@ const packageJson = JSON.parse(fs.readFileSync('./package.json', 'utf8'));
const metricsIPWhitelist = (req: Request, res: Response, next: NextFunction) => { const metricsIPWhitelist = (req: Request, res: Response, next: NextFunction) => {
const metricsConfig = configService.get('METRICS'); const metricsConfig = configService.get('METRICS');
const allowedIPs = metricsConfig.ALLOWED_IPS?.split(',').map((ip) => ip.trim()) || ['127.0.0.1']; const allowedIPs = metricsConfig.ALLOWED_IPS?.split(',').map((ip) => ip.trim()) || ['127.0.0.1'];
const clientIPs = [ const clientIP = req.ip || req.connection.remoteAddress || req.socket.remoteAddress;
req.ip,
req.connection.remoteAddress,
req.socket.remoteAddress,
req.headers['x-forwarded-for'],
].filter((ip) => ip !== undefined);
if (allowedIPs.filter((ip) => clientIPs.includes(ip)) === 0) { if (!allowedIPs.includes(clientIP)) {
return res.status(403).send('Forbidden: IP not allowed'); return res.status(403).send('Forbidden: IP not allowed');
} }

View File

@ -1,11 +1,9 @@
import { RouterBroker } from '@api/abstract/abstract.router'; import { RouterBroker } from '@api/abstract/abstract.router';
import { InstanceDto } from '@api/dto/instance.dto'; import { InstanceDto } from '@api/dto/instance.dto';
import { TemplateDeleteDto, TemplateDto, TemplateEditDto } from '@api/dto/template.dto'; import { TemplateDto } from '@api/dto/template.dto';
import { templateController } from '@api/server.module'; import { templateController } from '@api/server.module';
import { ConfigService } from '@config/env.config'; import { ConfigService } from '@config/env.config';
import { createMetaErrorResponse } from '@utils/errorResponse'; import { createMetaErrorResponse } from '@utils/errorResponse';
import { templateDeleteSchema } from '@validate/templateDelete.schema';
import { templateEditSchema } from '@validate/templateEdit.schema';
import { instanceSchema, templateSchema } from '@validate/validate.schema'; import { instanceSchema, templateSchema } from '@validate/validate.schema';
import { RequestHandler, Router } from 'express'; import { RequestHandler, Router } from 'express';
@ -37,38 +35,6 @@ export class TemplateRouter extends RouterBroker {
res.status(errorResponse.status).json(errorResponse); res.status(errorResponse.status).json(errorResponse);
} }
}) })
.post(this.routerPath('edit'), ...guards, async (req, res) => {
try {
const response = await this.dataValidate<TemplateEditDto>({
request: req,
schema: templateEditSchema,
ClassRef: TemplateEditDto,
execute: (instance, data) => templateController.editTemplate(instance, data),
});
res.status(HttpStatus.OK).json(response);
} catch (error) {
console.error('Template edit error:', error);
const errorResponse = createMetaErrorResponse(error, 'template_edit');
res.status(errorResponse.status).json(errorResponse);
}
})
.delete(this.routerPath('delete'), ...guards, async (req, res) => {
try {
const response = await this.dataValidate<TemplateDeleteDto>({
request: req,
schema: templateDeleteSchema,
ClassRef: TemplateDeleteDto,
execute: (instance, data) => templateController.deleteTemplate(instance, data),
});
res.status(HttpStatus.OK).json(response);
} catch (error) {
console.error('Template delete error:', error);
const errorResponse = createMetaErrorResponse(error, 'template_delete');
res.status(errorResponse.status).json(errorResponse);
}
})
.get(this.routerPath('find'), ...guards, async (req, res) => { .get(this.routerPath('find'), ...guards, async (req, res) => {
try { try {
const response = await this.dataValidate<InstanceDto>({ const response = await this.dataValidate<InstanceDto>({

View File

@ -82,7 +82,7 @@ const proxyService = new ProxyService(waMonitor);
export const proxyController = new ProxyController(proxyService, waMonitor); export const proxyController = new ProxyController(proxyService, waMonitor);
const chatwootService = new ChatwootService(waMonitor, configService, prismaRepository, chatwootCache); const chatwootService = new ChatwootService(waMonitor, configService, prismaRepository, chatwootCache);
export const chatwootController = new ChatwootController(chatwootService, configService); export const chatwootController = new ChatwootController(chatwootService, configService, prismaRepository);
const settingsService = new SettingsService(waMonitor); const settingsService = new SettingsService(waMonitor);
export const settingsController = new SettingsController(settingsService); export const settingsController = new SettingsController(settingsService);

View File

@ -60,7 +60,6 @@ export class ChannelStartupService {
this.instance.number = instance.number; this.instance.number = instance.number;
this.instance.token = instance.token; this.instance.token = instance.token;
this.instance.businessId = instance.businessId; this.instance.businessId = instance.businessId;
this.instance.ownerJid = instance.ownerJid;
if (this.configService.get<Chatwoot>('CHATWOOT').ENABLED && this.localChatwoot?.enabled) { if (this.configService.get<Chatwoot>('CHATWOOT').ENABLED && this.localChatwoot?.enabled) {
this.chatwootService.eventWhatsapp( this.chatwootService.eventWhatsapp(
@ -432,13 +431,7 @@ export class ChannelStartupService {
return data; return data;
} }
public async sendDataWebhook<T extends object = any>( public async sendDataWebhook<T extends object = any>(event: Events, data: T, local = true, integration?: string[]) {
event: Events,
data: T,
local = true,
integration?: string[],
extra?: Record<string, any>,
) {
const serverUrl = this.configService.get<HttpServer>('SERVER').URL; const serverUrl = this.configService.get<HttpServer>('SERVER').URL;
const tzoffset = new Date().getTimezoneOffset() * 60000; //offset in milliseconds const tzoffset = new Date().getTimezoneOffset() * 60000; //offset in milliseconds
const localISOTime = new Date(Date.now() - tzoffset).toISOString(); const localISOTime = new Date(Date.now() - tzoffset).toISOString();
@ -459,7 +452,6 @@ export class ChannelStartupService {
apiKey: expose && instanceApikey ? instanceApikey : null, apiKey: expose && instanceApikey ? instanceApikey : null,
local, local,
integration, integration,
extra,
}); });
} }
@ -498,23 +490,20 @@ export class ChannelStartupService {
} }
public async fetchContacts(query: Query<Contact>) { public async fetchContacts(query: Query<Contact>) {
const where: any = { const remoteJid = query?.where?.remoteJid
? query?.where?.remoteJid.includes('@')
? query.where?.remoteJid
: createJid(query.where?.remoteJid)
: null;
const where = {
instanceId: this.instanceId, instanceId: this.instanceId,
}; };
if (query?.where?.remoteJid) { if (remoteJid) {
const remoteJid = query.where.remoteJid.includes('@') ? query.where.remoteJid : createJid(query.where.remoteJid);
where['remoteJid'] = remoteJid; where['remoteJid'] = remoteJid;
} }
if (query?.where?.id) {
where['id'] = query.where.id;
}
if (query?.where?.pushName) {
where['pushName'] = query.where.pushName;
}
const contactFindManyArgs: Prisma.ContactFindManyArgs = { const contactFindManyArgs: Prisma.ContactFindManyArgs = {
where, where,
}; };
@ -543,12 +532,14 @@ export class ChannelStartupService {
public cleanMessageData(message: any) { public cleanMessageData(message: any) {
if (!message) return message; if (!message) return message;
const cleanedMessage = { ...message }; const cleanedMessage = { ...message };
if (cleanedMessage.message) { const mediaUrl = cleanedMessage.message.mediaUrl;
const { mediaUrl } = cleanedMessage.message;
delete cleanedMessage.message.base64; delete cleanedMessage.message.base64;
if (cleanedMessage.message) {
// Limpa imageMessage // Limpa imageMessage
if (cleanedMessage.message.imageMessage) { if (cleanedMessage.message.imageMessage) {
cleanedMessage.message.imageMessage = { cleanedMessage.message.imageMessage = {
@ -590,9 +581,9 @@ export class ChannelStartupService {
name: cleanedMessage.message.documentWithCaptionMessage.name, name: cleanedMessage.message.documentWithCaptionMessage.name,
}; };
} }
}
if (mediaUrl) cleanedMessage.message.mediaUrl = mediaUrl; if (mediaUrl) cleanedMessage.message.mediaUrl = mediaUrl;
}
return cleanedMessage; return cleanedMessage;
} }

View File

@ -38,22 +38,14 @@ export class WAMonitoringService {
private readonly logger = new Logger('WAMonitoringService'); private readonly logger = new Logger('WAMonitoringService');
public readonly waInstances: Record<string, any> = {}; public readonly waInstances: Record<string, any> = {};
private readonly delInstanceTimeouts: Record<string, NodeJS.Timeout> = {};
private readonly providerSession: ProviderSession; private readonly providerSession: ProviderSession;
public delInstanceTime(instance: string) { public delInstanceTime(instance: string) {
const time = this.configService.get<DelInstance>('DEL_INSTANCE'); const time = this.configService.get<DelInstance>('DEL_INSTANCE');
if (typeof time === 'number' && time > 0) { if (typeof time === 'number' && time > 0) {
// Clear previous timeout if exists setTimeout(
if (this.delInstanceTimeouts[instance]) {
clearTimeout(this.delInstanceTimeouts[instance]);
}
// Set new timeout and store reference
this.delInstanceTimeouts[instance] = setTimeout(
async () => { async () => {
try {
if (this.waInstances[instance]?.connectionStatus?.state !== 'open') { if (this.waInstances[instance]?.connectionStatus?.state !== 'open') {
if (this.waInstances[instance]?.connectionStatus?.state === 'connecting') { if (this.waInstances[instance]?.connectionStatus?.state === 'connecting') {
if ((await this.waInstances[instance].integration) === Integration.WHATSAPP_BAILEYS) { if ((await this.waInstances[instance].integration) === Integration.WHATSAPP_BAILEYS) {
@ -66,23 +58,12 @@ export class WAMonitoringService {
this.eventEmitter.emit('remove.instance', instance, 'inner'); this.eventEmitter.emit('remove.instance', instance, 'inner');
} }
} }
} finally {
// Clean up timeout reference
delete this.delInstanceTimeouts[instance];
}
}, },
1000 * 60 * time, 1000 * 60 * time,
); );
} }
} }
public clearDelInstanceTime(instance: string) {
if (this.delInstanceTimeouts[instance]) {
clearTimeout(this.delInstanceTimeouts[instance]);
delete this.delInstanceTimeouts[instance];
}
}
public async instanceInfo(instanceNames?: string[]): Promise<any> { public async instanceInfo(instanceNames?: string[]): Promise<any> {
if (instanceNames && instanceNames.length > 0) { if (instanceNames && instanceNames.length > 0) {
const inexistentInstances = instanceNames ? instanceNames.filter((instance) => !this.waInstances[instance]) : []; const inexistentInstances = instanceNames ? instanceNames.filter((instance) => !this.waInstances[instance]) : [];
@ -290,19 +271,9 @@ export class WAMonitoringService {
token: instanceData.token, token: instanceData.token,
number: instanceData.number, number: instanceData.number,
businessId: instanceData.businessId, businessId: instanceData.businessId,
ownerJid: instanceData.ownerJid,
}); });
if (instanceData.connectionStatus === 'open' || instanceData.connectionStatus === 'connecting') {
this.logger.info(
`Auto-connecting instance "${instanceData.instanceName}" (status: ${instanceData.connectionStatus})`,
);
await instance.connectToWhatsapp(); await instance.connectToWhatsapp();
} else {
this.logger.info(
`Skipping auto-connect for instance "${instanceData.instanceName}" (status: ${instanceData.connectionStatus || 'close'})`,
);
}
this.waInstances[instanceData.instanceName] = instance; this.waInstances[instanceData.instanceName] = instance;
} }
@ -328,7 +299,6 @@ export class WAMonitoringService {
token: instanceData.token, token: instanceData.token,
number: instanceData.number, number: instanceData.number,
businessId: instanceData.businessId, businessId: instanceData.businessId,
connectionStatus: instanceData.connectionStatus as any, // Pass connection status
}; };
this.setInstance(instance); this.setInstance(instance);
@ -357,8 +327,6 @@ export class WAMonitoringService {
token: instance.token, token: instance.token,
number: instance.number, number: instance.number,
businessId: instance.businessId, businessId: instance.businessId,
ownerJid: instance.ownerJid,
connectionStatus: instance.connectionStatus as any, // Pass connection status
}); });
}), }),
); );
@ -383,7 +351,6 @@ export class WAMonitoringService {
integration: instance.integration, integration: instance.integration,
token: instance.token, token: instance.token,
businessId: instance.businessId, businessId: instance.businessId,
connectionStatus: instance.connectionStatus as any, // Pass connection status
}); });
}), }),
); );
@ -394,8 +361,6 @@ export class WAMonitoringService {
try { try {
await this.waInstances[instanceName]?.sendDataWebhook(Events.REMOVE_INSTANCE, null); await this.waInstances[instanceName]?.sendDataWebhook(Events.REMOVE_INSTANCE, null);
this.clearDelInstanceTime(instanceName);
this.cleaningUp(instanceName); this.cleaningUp(instanceName);
this.cleaningStoreData(instanceName); this.cleaningStoreData(instanceName);
} finally { } finally {
@ -412,8 +377,6 @@ export class WAMonitoringService {
try { try {
await this.waInstances[instanceName]?.sendDataWebhook(Events.LOGOUT_INSTANCE, null); await this.waInstances[instanceName]?.sendDataWebhook(Events.LOGOUT_INSTANCE, null);
this.clearDelInstanceTime(instanceName);
if (this.configService.get<Chatwoot>('CHATWOOT').ENABLED) { if (this.configService.get<Chatwoot>('CHATWOOT').ENABLED) {
this.waInstances[instanceName]?.clearCacheChatwoot(); this.waInstances[instanceName]?.clearCacheChatwoot();
} }

View File

@ -88,77 +88,6 @@ export class TemplateService {
} }
} }
public async edit(
instance: InstanceDto,
data: { templateId: string; category?: string; components?: any; allowCategoryChange?: boolean; ttl?: number },
) {
const getInstance = await this.waMonitor.waInstances[instance.instanceName].instance;
if (!getInstance) {
throw new Error('Instance not found');
}
this.businessId = getInstance.businessId;
this.token = getInstance.token;
const payload: Record<string, unknown> = {};
if (typeof data.category === 'string') payload.category = data.category;
if (typeof data.allowCategoryChange === 'boolean') payload.allow_category_change = data.allowCategoryChange;
if (typeof data.ttl === 'number') payload.time_to_live = data.ttl;
if (data.components) payload.components = data.components;
const response = await this.requestEditTemplate(data.templateId, payload);
if (!response || response.error) {
if (response && response.error) {
const metaError = new Error(response.error.message || 'WhatsApp API Error');
(metaError as any).template = response.error;
throw metaError;
}
throw new Error('Error to edit template');
}
return response;
}
public async delete(instance: InstanceDto, data: { name: string; hsmId?: string }) {
const getInstance = await this.waMonitor.waInstances[instance.instanceName].instance;
if (!getInstance) {
throw new Error('Instance not found');
}
this.businessId = getInstance.businessId;
this.token = getInstance.token;
const response = await this.requestDeleteTemplate({ name: data.name, hsm_id: data.hsmId });
if (!response || response.error) {
if (response && response.error) {
const metaError = new Error(response.error.message || 'WhatsApp API Error');
(metaError as any).template = response.error;
throw metaError;
}
throw new Error('Error to delete template');
}
try {
// Best-effort local cleanup of stored template metadata
await this.prismaRepository.template.deleteMany({
where: {
OR: [
{ name: data.name, instanceId: getInstance.id },
data.hsmId ? { templateId: data.hsmId, instanceId: getInstance.id } : undefined,
].filter(Boolean) as any,
},
});
} catch (err) {
this.logger.warn(
`Failed to cleanup local template records after delete: ${(err as Error)?.message || String(err)}`,
);
}
return response;
}
private async requestTemplate(data: any, method: string) { private async requestTemplate(data: any, method: string) {
try { try {
let urlServer = this.configService.get<WaBusiness>('WA_BUSINESS').URL; let urlServer = this.configService.get<WaBusiness>('WA_BUSINESS').URL;
@ -187,38 +116,4 @@ export class TemplateService {
throw new Error(`Connection error: ${e.message}`); throw new Error(`Connection error: ${e.message}`);
} }
} }
private async requestEditTemplate(templateId: string, data: any) {
try {
let urlServer = this.configService.get<WaBusiness>('WA_BUSINESS').URL;
const version = this.configService.get<WaBusiness>('WA_BUSINESS').VERSION;
urlServer = `${urlServer}/${version}/${templateId}`;
const headers = { 'Content-Type': 'application/json', Authorization: `Bearer ${this.token}` };
const result = await axios.post(urlServer, data, { headers });
return result.data;
} catch (e) {
this.logger.error(
'WhatsApp API request error: ' + (e.response?.data ? JSON.stringify(e.response?.data) : e.message),
);
if (e.response?.data) return e.response.data;
throw new Error(`Connection error: ${e.message}`);
}
}
private async requestDeleteTemplate(params: { name: string; hsm_id?: string }) {
try {
let urlServer = this.configService.get<WaBusiness>('WA_BUSINESS').URL;
const version = this.configService.get<WaBusiness>('WA_BUSINESS').VERSION;
urlServer = `${urlServer}/${version}/${this.businessId}/message_templates`;
const headers = { Authorization: `Bearer ${this.token}` };
const result = await axios.delete(urlServer, { headers, params });
return result.data;
} catch (e) {
this.logger.error(
'WhatsApp API request error: ' + (e.response?.data ? JSON.stringify(e.response?.data) : e.message),
);
if (e.response?.data) return e.response.data;
throw new Error(`Connection error: ${e.message}`);
}
}
} }

View File

@ -52,7 +52,6 @@ export declare namespace wa {
pairingCode?: string; pairingCode?: string;
authState?: { state: AuthenticationState; saveCreds: () => void }; authState?: { state: AuthenticationState; saveCreds: () => void };
name?: string; name?: string;
ownerJid?: string;
wuid?: string; wuid?: string;
profileName?: string; profileName?: string;
profilePictureUrl?: string; profilePictureUrl?: string;

View File

@ -26,8 +26,8 @@ import cors from 'cors';
import express, { json, NextFunction, Request, Response, urlencoded } from 'express'; import express, { json, NextFunction, Request, Response, urlencoded } from 'express';
import { join } from 'path'; import { join } from 'path';
async function initWA() { function initWA() {
await waMonitor.loadInstance(); waMonitor.loadInstance();
} }
async function bootstrap() { async function bootstrap() {
@ -159,9 +159,7 @@ async function bootstrap() {
server.listen(httpServer.PORT, () => logger.log(httpServer.TYPE.toUpperCase() + ' - ON: ' + httpServer.PORT)); server.listen(httpServer.PORT, () => logger.log(httpServer.TYPE.toUpperCase() + ' - ON: ' + httpServer.PORT));
initWA().catch((error) => { initWA();
logger.error('Error loading instances: ' + error);
});
onUnexpectedError(); onUnexpectedError();
} }

View File

@ -1,7 +1,5 @@
import { socksDispatcher } from 'fetch-socks';
import { HttpsProxyAgent } from 'https-proxy-agent'; import { HttpsProxyAgent } from 'https-proxy-agent';
import { SocksProxyAgent } from 'socks-proxy-agent'; import { SocksProxyAgent } from 'socks-proxy-agent';
import { ProxyAgent } from 'undici';
type Proxy = { type Proxy = {
host: string; host: string;
@ -19,23 +17,12 @@ function selectProxyAgent(proxyUrl: string): HttpsProxyAgent<string> | SocksProx
// the end so, we add the protocol constants without the `:` to avoid confusion. // the end so, we add the protocol constants without the `:` to avoid confusion.
const PROXY_HTTP_PROTOCOL = 'http:'; const PROXY_HTTP_PROTOCOL = 'http:';
const PROXY_SOCKS_PROTOCOL = 'socks:'; const PROXY_SOCKS_PROTOCOL = 'socks:';
const PROXY_SOCKS5_PROTOCOL = 'socks5:';
switch (url.protocol) { switch (url.protocol) {
case PROXY_HTTP_PROTOCOL: case PROXY_HTTP_PROTOCOL:
return new HttpsProxyAgent(url); return new HttpsProxyAgent(url);
case PROXY_SOCKS_PROTOCOL: case PROXY_SOCKS_PROTOCOL:
case PROXY_SOCKS5_PROTOCOL: { return new SocksProxyAgent(url);
let urlSocks = '';
if (url.username && url.password) {
urlSocks = `socks://${url.username}:${url.password}@${url.hostname}:${url.port}`;
} else {
urlSocks = `socks://${url.hostname}:${url.port}`;
}
return new SocksProxyAgent(urlSocks);
}
default: default:
throw new Error(`Unsupported proxy protocol: ${url.protocol}`); throw new Error(`Unsupported proxy protocol: ${url.protocol}`);
} }
@ -55,57 +42,3 @@ export function makeProxyAgent(proxy: Proxy | string): HttpsProxyAgent<string> |
return selectProxyAgent(proxyUrl); return selectProxyAgent(proxyUrl);
} }
export function makeProxyAgentUndici(proxy: Proxy | string): ProxyAgent {
let proxyUrl: string;
let protocol: string;
if (typeof proxy === 'string') {
const url = new URL(proxy);
protocol = url.protocol.replace(':', '');
proxyUrl = proxy;
} else {
const { host, password, port, protocol: proto, username } = proxy;
protocol = (proto || 'http').replace(':', '');
if (protocol === 'socks') {
protocol = 'socks5';
}
const auth = username && password ? `${username}:${password}@` : '';
proxyUrl = `${protocol}://${auth}${host}:${port}`;
}
protocol = protocol.toLowerCase();
const PROXY_HTTP_PROTOCOL = 'http';
const PROXY_HTTPS_PROTOCOL = 'https';
const PROXY_SOCKS4_PROTOCOL = 'socks4';
const PROXY_SOCKS5_PROTOCOL = 'socks5';
switch (protocol) {
case PROXY_HTTP_PROTOCOL:
case PROXY_HTTPS_PROTOCOL:
return new ProxyAgent(proxyUrl);
case PROXY_SOCKS4_PROTOCOL:
case PROXY_SOCKS5_PROTOCOL: {
let type: 4 | 5 = 5;
if (PROXY_SOCKS4_PROTOCOL === protocol) type = 4;
const url = new URL(proxyUrl);
return socksDispatcher({
type: type,
host: url.hostname,
port: Number(url.port),
userId: url.username || undefined,
password: url.password || undefined,
});
}
default:
throw new Error(`Unsupported proxy protocol: ${protocol}`);
}
}

View File

@ -1,10 +1,7 @@
import { prismaRepository } from '@api/server.module'; import { prismaRepository } from '@api/server.module';
import { configService, Database } from '@config/env.config'; import { configService, Database } from '@config/env.config';
import { Logger } from '@config/logger.config';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
const logger = new Logger('OnWhatsappCache');
function getAvailableNumbers(remoteJid: string) { function getAvailableNumbers(remoteJid: string) {
const numbersAvailable: string[] = []; const numbersAvailable: string[] = [];
@ -14,11 +11,6 @@ function getAvailableNumbers(remoteJid: string) {
const [number, domain] = remoteJid.split('@'); const [number, domain] = remoteJid.split('@');
// TODO: Se já for @lid, retornar apenas ele mesmo SEM adicionar @domain novamente
if (domain === 'lid' || domain === 'g.us') {
return [remoteJid]; // Retorna direto para @lid e @g.us
}
// Brazilian numbers // Brazilian numbers
if (remoteJid.startsWith('55')) { if (remoteJid.startsWith('55')) {
const numberWithDigit = const numberWithDigit =
@ -55,128 +47,36 @@ function getAvailableNumbers(remoteJid: string) {
numbersAvailable.push(remoteJid); numbersAvailable.push(remoteJid);
} }
// TODO: Adiciona @domain apenas para números que não são @lid
return numbersAvailable.map((number) => `${number}@${domain}`); return numbersAvailable.map((number) => `${number}@${domain}`);
} }
interface ISaveOnWhatsappCacheParams { interface ISaveOnWhatsappCacheParams {
remoteJid: string; remoteJid: string;
remoteJidAlt?: string; lid?: string;
lid?: 'lid' | undefined;
}
function normalizeJid(jid: string | null | undefined): string | null {
if (!jid) return null;
return jid.startsWith('+') ? jid.slice(1) : jid;
} }
export async function saveOnWhatsappCache(data: ISaveOnWhatsappCacheParams[]) { export async function saveOnWhatsappCache(data: ISaveOnWhatsappCacheParams[]) {
if (!configService.get<Database>('DATABASE').SAVE_DATA.IS_ON_WHATSAPP) { if (configService.get<Database>('DATABASE').SAVE_DATA.IS_ON_WHATSAPP) {
return; const upsertsQuery = data.map((item) => {
} const remoteJid = item.remoteJid.startsWith('+') ? item.remoteJid.slice(1) : item.remoteJid;
const numbersAvailable = getAvailableNumbers(remoteJid);
// Processa todos os itens em paralelo para melhor performance return prismaRepository.isOnWhatsapp.upsert({
const processingPromises = data.map(async (item) => { create: {
try {
const remoteJid = normalizeJid(item.remoteJid);
if (!remoteJid) {
logger.warn('[saveOnWhatsappCache] Item skipped, missing remoteJid.');
return;
}
const altJidNormalized = normalizeJid(item.remoteJidAlt);
const lidAltJid = altJidNormalized && altJidNormalized.includes('@lid') ? altJidNormalized : null;
const baseJids = [remoteJid]; // Garante que o remoteJid esteja na lista inicial
if (lidAltJid) {
baseJids.push(lidAltJid);
}
const expandedJids = baseJids.flatMap((jid) => getAvailableNumbers(jid));
// 1. Busca entrada por jidOptions e também remoteJid
// Às vezes acontece do remoteJid atual NÃO ESTAR no jidOptions ainda, ocasionando o erro:
// 'Unique constraint failed on the fields: (`remoteJid`)'
// Isso acontece principalmente em grupos que possuem o número do criador no ID (ex.: '559911223345-1234567890@g.us')
const existingRecord = await prismaRepository.isOnWhatsapp.findFirst({
where: {
OR: [
...expandedJids.map((jid) => ({ jidOptions: { contains: jid } })),
{ remoteJid: remoteJid }, // TODO: Descobrir o motivo que causa o remoteJid não estar (às vezes) incluso na lista de jidOptions
],
},
});
logger.verbose(
`[saveOnWhatsappCache] Register exists for [${expandedJids.join(',')}]? => ${existingRecord ? existingRecord.remoteJid : 'Not found'}`,
);
// 2. Unifica todos os JIDs usando um Set para garantir valores únicos
const finalJidOptions = new Set(expandedJids);
if (lidAltJid) {
finalJidOptions.add(lidAltJid);
}
if (existingRecord?.jidOptions) {
existingRecord.jidOptions.split(',').forEach((jid) => finalJidOptions.add(jid));
}
// 3. Prepara o payload final
// Ordena os JIDs para garantir consistência na string final
const sortedJidOptions = [...finalJidOptions].sort();
const newJidOptionsString = sortedJidOptions.join(',');
const newLid = item.lid === 'lid' || item.remoteJid?.includes('@lid') ? 'lid' : null;
const dataPayload = {
remoteJid: remoteJid, remoteJid: remoteJid,
jidOptions: newJidOptionsString, jidOptions: numbersAvailable.join(','),
lid: newLid, lid: item.lid,
}; },
update: {
// 4. Decide entre Criar ou Atualizar jidOptions: numbersAvailable.join(','),
if (existingRecord) { lid: item.lid,
// Compara a string de JIDs ordenada existente com a nova },
const existingJidOptionsString = existingRecord.jidOptions where: { remoteJid: remoteJid },
? existingRecord.jidOptions.split(',').sort().join(',')
: '';
const isDataSame =
existingRecord.remoteJid === dataPayload.remoteJid &&
existingJidOptionsString === dataPayload.jidOptions &&
existingRecord.lid === dataPayload.lid;
if (isDataSame) {
logger.verbose(`[saveOnWhatsappCache] Data for ${remoteJid} is already up-to-date. Skipping update.`);
return; // Pula para o próximo item
}
// Os dados são diferentes, então atualiza
logger.verbose(
`[saveOnWhatsappCache] Register exists, updating: remoteJid=${remoteJid}, jidOptions=${dataPayload.jidOptions}, lid=${dataPayload.lid}`,
);
await prismaRepository.isOnWhatsapp.update({
where: { id: existingRecord.id },
data: dataPayload,
}); });
} else {
// Cria nova entrada
logger.verbose(
`[saveOnWhatsappCache] Register does not exist, creating: remoteJid=${remoteJid}, jidOptions=${dataPayload.jidOptions}, lid=${dataPayload.lid}`,
);
await prismaRepository.isOnWhatsapp.create({
data: dataPayload,
});
}
} catch (e) {
// Loga o erro mas não para a execução dos outros promises
logger.error(`[saveOnWhatsappCache] Error processing item for ${item.remoteJid}: `);
logger.error(e);
}
}); });
// Espera todas as operações paralelas terminarem await prismaRepository.$transaction(upsertsQuery);
await Promise.allSettled(processingPromises); }
} }
export async function getOnWhatsappCache(remoteJids: string[]) { export async function getOnWhatsappCache(remoteJids: string[]) {

View File

@ -1,7 +1,6 @@
import { prismaRepository } from '@api/server.module'; import { prismaRepository } from '@api/server.module';
import { CacheService } from '@api/services/cache.service'; import { CacheService } from '@api/services/cache.service';
import { CacheConf, configService } from '@config/env.config'; import { CacheConf, configService } from '@config/env.config';
import { Logger } from '@config/logger.config';
import { INSTANCE_DIR } from '@config/path.config'; import { INSTANCE_DIR } from '@config/path.config';
import { AuthenticationState, BufferJSON, initAuthCreds, WAProto as proto } from 'baileys'; import { AuthenticationState, BufferJSON, initAuthCreds, WAProto as proto } from 'baileys';
import fs from 'fs/promises'; import fs from 'fs/promises';
@ -74,15 +73,12 @@ async function fileExists(file: string): Promise<any> {
} }
} }
const logger = new Logger('useMultiFileAuthStatePrisma');
export default async function useMultiFileAuthStatePrisma( export default async function useMultiFileAuthStatePrisma(
sessionId: string, sessionId: string,
cache: CacheService, cache: CacheService,
): Promise<{ ): Promise<{
state: AuthenticationState; state: AuthenticationState;
saveCreds: () => Promise<void>; saveCreds: () => Promise<void>;
removeCreds: () => Promise<void>;
}> { }> {
const localFolder = path.join(INSTANCE_DIR, sessionId); const localFolder = path.join(INSTANCE_DIR, sessionId);
const localFile = (key: string) => path.join(localFolder, fixFileName(key) + '.json'); const localFile = (key: string) => path.join(localFolder, fixFileName(key) + '.json');
@ -146,26 +142,6 @@ export default async function useMultiFileAuthStatePrisma(
} }
} }
async function removeCreds(): Promise<any> {
const cacheConfig = configService.get<CacheConf>('CACHE');
// Redis
try {
if (cacheConfig.REDIS.ENABLED) {
await cache.delete(sessionId);
logger.info({ action: 'redis.delete', sessionId });
return;
}
} catch (err) {
logger.warn({ action: 'redis.delete', sessionId, err });
}
logger.info({ action: 'auth.key.delete', sessionId });
await deleteAuthKey(sessionId);
}
let creds = await readData('creds'); let creds = await readData('creds');
if (!creds) { if (!creds) {
creds = initAuthCreds(); creds = initAuthCreds();
@ -207,7 +183,5 @@ export default async function useMultiFileAuthStatePrisma(
saveCreds: () => { saveCreds: () => {
return writeData(creds, 'creds'); return writeData(creds, 'creds');
}, },
removeCreds,
}; };
} }

View File

@ -39,11 +39,7 @@ import { Logger } from '@config/logger.config';
import { AuthenticationCreds, AuthenticationState, BufferJSON, initAuthCreds, proto, SignalDataTypeMap } from 'baileys'; import { AuthenticationCreds, AuthenticationState, BufferJSON, initAuthCreds, proto, SignalDataTypeMap } from 'baileys';
import { isNotEmpty } from 'class-validator'; import { isNotEmpty } from 'class-validator';
export type AuthState = { export type AuthState = { state: AuthenticationState; saveCreds: () => Promise<void> };
state: AuthenticationState;
saveCreds: () => Promise<void>;
removeCreds: () => Promise<void>;
};
export class AuthStateProvider { export class AuthStateProvider {
constructor(private readonly providerFiles: ProviderFiles) {} constructor(private readonly providerFiles: ProviderFiles) {}
@ -90,18 +86,6 @@ export class AuthStateProvider {
return response; return response;
}; };
const removeCreds = async () => {
const [response, error] = await this.providerFiles.removeSession(instance);
if (error) {
// this.logger.error(['removeData', error?.message, error?.stack]);
return;
}
logger.info({ action: 'remove.session', instance, response });
return;
};
const creds: AuthenticationCreds = (await readData('creds')) || initAuthCreds(); const creds: AuthenticationCreds = (await readData('creds')) || initAuthCreds();
return { return {
@ -142,10 +126,6 @@ export class AuthStateProvider {
saveCreds: async () => { saveCreds: async () => {
return await writeData(creds, 'creds'); return await writeData(creds, 'creds');
}, },
removeCreds,
}; };
} }
} }
const logger = new Logger('useMultiFileAuthStatePrisma');

View File

@ -8,7 +8,6 @@ export async function useMultiFileAuthStateRedisDb(
): Promise<{ ): Promise<{
state: AuthenticationState; state: AuthenticationState;
saveCreds: () => Promise<void>; saveCreds: () => Promise<void>;
removeCreds: () => Promise<void>;
}> { }> {
const logger = new Logger('useMultiFileAuthStateRedisDb'); const logger = new Logger('useMultiFileAuthStateRedisDb');
@ -37,16 +36,6 @@ export async function useMultiFileAuthStateRedisDb(
} }
}; };
async function removeCreds(): Promise<any> {
try {
logger.warn({ action: 'redis.delete', instanceName });
return await cache.delete(instanceName);
} catch {
return;
}
}
const creds: AuthenticationCreds = (await readData('creds')) || initAuthCreds(); const creds: AuthenticationCreds = (await readData('creds')) || initAuthCreds();
return { return {
@ -87,7 +76,5 @@ export async function useMultiFileAuthStateRedisDb(
saveCreds: async () => { saveCreds: async () => {
return await writeData(creds, 'creds'); return await writeData(creds, 'creds');
}, },
removeCreds,
}; };
} }

View File

@ -195,9 +195,8 @@ export const contactValidateSchema: JSONSchema7 = {
_id: { type: 'string', minLength: 1 }, _id: { type: 'string', minLength: 1 },
pushName: { type: 'string', minLength: 1 }, pushName: { type: 'string', minLength: 1 },
id: { type: 'string', minLength: 1 }, id: { type: 'string', minLength: 1 },
remoteJid: { type: 'string', minLength: 1 },
}, },
...isNotEmpty('_id', 'id', 'pushName', 'remoteJid'), ...isNotEmpty('_id', 'id', 'pushName'),
}, },
}, },
}; };

View File

@ -1,32 +0,0 @@
import { JSONSchema7 } from 'json-schema';
import { v4 } from 'uuid';
const isNotEmpty = (...propertyNames: string[]): JSONSchema7 => {
const properties: Record<string, unknown> = {};
propertyNames.forEach(
(property) =>
(properties[property] = {
minLength: 1,
description: `The "${property}" cannot be empty`,
}),
);
return {
if: {
propertyNames: {
enum: [...propertyNames],
},
},
then: { properties },
} as JSONSchema7;
};
export const templateDeleteSchema: JSONSchema7 = {
$id: v4(),
type: 'object',
properties: {
name: { type: 'string' },
hsmId: { type: 'string' },
},
required: ['name'],
...isNotEmpty('name'),
};

View File

@ -1,35 +0,0 @@
import { JSONSchema7 } from 'json-schema';
import { v4 } from 'uuid';
const isNotEmpty = (...propertyNames: string[]): JSONSchema7 => {
const properties: Record<string, unknown> = {};
propertyNames.forEach(
(property) =>
(properties[property] = {
minLength: 1,
description: `The "${property}" cannot be empty`,
}),
);
return {
if: {
propertyNames: {
enum: [...propertyNames],
},
},
then: { properties },
} as JSONSchema7;
};
export const templateEditSchema: JSONSchema7 = {
$id: v4(),
type: 'object',
properties: {
templateId: { type: 'string' },
category: { type: 'string', enum: ['AUTHENTICATION', 'MARKETING', 'UTILITY'] },
allowCategoryChange: { type: 'boolean' },
ttl: { type: 'number' },
components: { type: 'array' },
},
required: ['templateId'],
...isNotEmpty('templateId'),
};

View File

@ -8,7 +8,5 @@ export * from './message.schema';
export * from './proxy.schema'; export * from './proxy.schema';
export * from './settings.schema'; export * from './settings.schema';
export * from './template.schema'; export * from './template.schema';
export * from './templateDelete.schema';
export * from './templateEdit.schema';
export * from '@api/integrations/chatbot/chatbot.schema'; export * from '@api/integrations/chatbot/chatbot.schema';
export * from '@api/integrations/event/event.schema'; export * from '@api/integrations/event/event.schema';