Merge branch 'release/2.3.5'

This commit is contained in:
Davidson Gomes 2025-10-15 09:38:34 -03:00
commit 501b06d133
25 changed files with 3802 additions and 3907 deletions

View File

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

View File

@ -1,3 +1,44 @@
# 2.3.5 (2025-10-15)
### Features
* **Chatwoot Enhancements**: Comprehensive improvements to message handling, editing, deletion and i18n
* **Participants Data**: Add participantsData field maintaining backward compatibility for group participants
* **LID to Phone Number**: Convert LID to phoneNumber on group participants
* **Docker Configurations**: Add Kafka and frontend services to Docker configurations
### Fixed
* **Kafka Migration**: Fixed PostgreSQL migration error for Kafka integration
- Corrected table reference from `"public"."Instance"` to `"Instance"` in foreign key constraint
- Fixed `ERROR: relation "public.Instance" does not exist` issue in migration `20250918182355_add_kafka_integration`
- Aligned table naming convention with other Evolution API migrations for consistency
- Resolved database migration failure that prevented Kafka integration setup
* **Update Baileys Version**: v7.0.0-rc.5 with compatibility fixes
- Fixed assertSessions signature compatibility using type assertion
- Fixed incompatibility in voice call (wavoip) with new Baileys version
- Handle undefined status in update by defaulting to 'DELETED'
* **Chatwoot Improvements**: Multiple fixes for enhanced reliability
- Correct chatId extraction for non-group JIDs
- Resolve webhook timeout on deletion with 5+ images
- Improve error handling in Chatwoot messages
- Adjust conversation verification logic and cache
- Optimize conversation reopening logic and connection notification
- Fix conversation reopening and connection loop
* **Baileys Message Handling**: Enhanced message processing
- Add warning log for messages not found
- Fix message verification in Baileys service
- Simplify linkPreview handling in BaileysStartupService
* **Media Validation**: Fix media content validation
* **PostgreSQL Connection**: Refactor connection with PostgreSQL and improve message handling
### Code Quality & Refactoring
* **Exponential Backoff**: Implement exponential backoff patterns and extract magic numbers to constants
* **TypeScript Build**: Update TypeScript build process and dependencies
###
# 2.3.4 (2025-09-23)
### Features

View File

@ -0,0 +1,51 @@
version: '3.3'
services:
zookeeper:
container_name: zookeeper
image: confluentinc/cp-zookeeper:7.5.0
environment:
- ZOOKEEPER_CLIENT_PORT=2181
- ZOOKEEPER_TICK_TIME=2000
- ZOOKEEPER_SYNC_LIMIT=2
volumes:
- zookeeper_data:/var/lib/zookeeper/
ports:
- 2181:2181
kafka:
container_name: kafka
image: confluentinc/cp-kafka:7.5.0
depends_on:
- zookeeper
environment:
- KAFKA_BROKER_ID=1
- KAFKA_ZOOKEEPER_CONNECT=zookeeper:2181
- KAFKA_LISTENER_SECURITY_PROTOCOL_MAP=PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT,OUTSIDE:PLAINTEXT
- KAFKA_ADVERTISED_LISTENERS=PLAINTEXT://kafka:29092,PLAINTEXT_HOST://localhost:9092,OUTSIDE://host.docker.internal:9094
- KAFKA_INTER_BROKER_LISTENER_NAME=PLAINTEXT
- KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR=1
- KAFKA_TRANSACTION_STATE_LOG_MIN_ISR=1
- KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR=1
- KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS=0
- KAFKA_AUTO_CREATE_TOPICS_ENABLE=true
- KAFKA_LOG_RETENTION_HOURS=168
- KAFKA_LOG_SEGMENT_BYTES=1073741824
- KAFKA_LOG_RETENTION_CHECK_INTERVAL_MS=300000
- KAFKA_COMPRESSION_TYPE=gzip
ports:
- 29092:29092
- 9092:9092
- 9094:9094
volumes:
- kafka_data:/var/lib/kafka/data
volumes:
zookeeper_data:
kafka_data:
networks:
evolution-net:
name: evolution-net
driver: bridge

View File

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

View File

@ -15,6 +15,16 @@ services:
expose:
- 8080
frontend:
container_name: evolution_frontend
image: evolution/manager:local
build: ./evolution-manager-v2
restart: always
ports:
- "3000:80"
networks:
- evolution-net
volumes:
evolution_instances:

View File

@ -20,6 +20,15 @@ services:
expose:
- "8080"
frontend:
container_name: evolution_frontend
image: evoapicloud/evolution-manager:latest
restart: always
ports:
- "3000:80"
networks:
- evolution-net
redis:
container_name: evolution_redis
image: redis:latest

@ -1 +1 @@
Subproject commit fcb38dd407b89697b7a7154cfd873f76729e6ece
Subproject commit c25a36b8f7eb0afba8ea048f38fb558a66e6c659

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 25 KiB

481
manager/dist/assets/index-Cv80sTx-.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -5,8 +5,8 @@
<link rel="icon" type="image/png" href="https://evolution-api.com/files/evo/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Evolution Manager</title>
<script type="module" crossorigin src="/assets/index-DJ2Q5K8k.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DxAxQfZR.css">
<script type="module" crossorigin src="/assets/index-Cv80sTx-.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-ScT-CzQp.css">
</head>
<body>
<div id="root"></div>

4
manager_install.sh Executable file
View File

@ -0,0 +1,4 @@
#! /bin/bash
rm -rf manager/dist
cp -r evolution-manager-v2/dist manager/dist

5922
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"name": "evolution-api",
"version": "2.3.4",
"version": "2.3.5",
"description": "Rest api for communication with WhatsApp",
"main": "./dist/main.js",
"type": "commonjs",
@ -56,7 +56,7 @@
"eslint --fix"
],
"src/**/*.ts": [
"sh -c 'npm run build'"
"sh -c 'tsc --noEmit'"
]
},
"config": {
@ -77,7 +77,7 @@
"amqplib": "^0.10.5",
"audio-decode": "^2.2.3",
"axios": "^1.7.9",
"baileys": "^7.0.0-rc.3",
"baileys": "^7.0.0-rc.5",
"class-validator": "^0.14.1",
"compression": "^1.7.5",
"cors": "^2.8.5",
@ -126,6 +126,8 @@
"devDependencies": {
"@commitlint/cli": "^19.8.1",
"@commitlint/config-conventional": "^19.8.1",
"@swc/core": "^1.13.5",
"@swc/helpers": "^0.5.17",
"@types/compression": "^1.7.5",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.18",

View File

@ -1,5 +1,5 @@
-- CreateTable
CREATE TABLE "public"."Kafka" (
CREATE TABLE "Kafka" (
"id" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT false,
"events" JSONB NOT NULL,
@ -11,7 +11,7 @@ CREATE TABLE "public"."Kafka" (
);
-- CreateIndex
CREATE UNIQUE INDEX "Kafka_instanceId_key" ON "public"."Kafka"("instanceId");
CREATE UNIQUE INDEX "Kafka_instanceId_key" ON "Kafka"("instanceId");
-- AddForeignKey
ALTER TABLE "public"."Kafka" ADD CONSTRAINT "Kafka_instanceId_fkey" FOREIGN KEY ("instanceId") REFERENCES "public"."Instance"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Kafka" ADD CONSTRAINT "Kafka_instanceId_fkey" FOREIGN KEY ("instanceId") REFERENCES "Instance"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -16,6 +16,7 @@ import { Events, wa } from '@api/types/wa.types';
import { AudioConverter, Chatwoot, ConfigService, Openai, S3 } from '@config/env.config';
import { BadRequestException, InternalServerErrorException } from '@exceptions';
import { createJid } from '@utils/createJid';
import { sendTelemetry } from '@utils/sendTelemetry';
import axios from 'axios';
import { isBase64, isURL } from 'class-validator';
import EventEmitter2 from 'eventemitter2';
@ -171,6 +172,8 @@ export class EvolutionStartupService extends ChannelStartupService {
this.logger.log(messageRaw);
sendTelemetry(`received.message.${messageRaw.messageType ?? 'unknown'}`);
this.sendDataWebhook(Events.MESSAGES_UPSERT, messageRaw);
await chatbotController.emit({

View File

@ -24,6 +24,7 @@ import { AudioConverter, Chatwoot, ConfigService, Database, Openai, S3, WaBusine
import { BadRequestException, InternalServerErrorException } from '@exceptions';
import { createJid } from '@utils/createJid';
import { status } from '@utils/renderStatus';
import { sendTelemetry } from '@utils/sendTelemetry';
import axios from 'axios';
import { arrayUnique, isURL } from 'class-validator';
import EventEmitter2 from 'eventemitter2';
@ -655,6 +656,8 @@ export class BusinessStartupService extends ChannelStartupService {
this.logger.log(messageRaw);
sendTelemetry(`received.message.${messageRaw.messageType ?? 'unknown'}`);
this.sendDataWebhook(Events.MESSAGES_UPSERT, messageRaw);
await chatbotController.emit({

View File

@ -71,7 +71,7 @@ export const useVoiceCallsBaileys = async (
socket.on('assertSessions', async (jids, force, callback) => {
try {
const response = await baileys_sock.assertSessions(jids, force);
const response = await baileys_sock.assertSessions(jids);
callback(response);

View File

@ -85,6 +85,7 @@ import { fetchLatestWaWebVersion } from '@utils/fetchLatestWaWebVersion';
import { makeProxyAgent } from '@utils/makeProxyAgent';
import { getOnWhatsappCache, saveOnWhatsappCache } from '@utils/onWhatsappCache';
import { status } from '@utils/renderStatus';
import { sendTelemetry } from '@utils/sendTelemetry';
import useMultiFileAuthStatePrisma from '@utils/use-multi-file-auth-state-prisma';
import { AuthStateProvider } from '@utils/use-multi-file-auth-state-provider-files';
import { useMultiFileAuthStateRedisDb } from '@utils/use-multi-file-auth-state-redis-db';
@ -152,13 +153,7 @@ import { v4 } from 'uuid';
import { BaileysMessageProcessor } from './baileysMessage.processor';
import { useVoiceCallsBaileys } from './voiceCalls/useVoiceCallsBaileys';
export interface ExtendedMessageKey extends WAMessageKey {
senderPn?: string;
previousRemoteJid?: string | null;
}
export interface ExtendedIMessageKey extends proto.IMessageKey {
senderPn?: string;
remoteJidAlt?: string;
participantAlt?: string;
server_id?: string;
@ -254,6 +249,10 @@ export class BaileysStartupService extends ChannelStartupService {
private endSession = false;
private logBaileys = this.configService.get<Log>('LOG').BAILEYS;
// Cache TTL constants (in seconds)
private readonly MESSAGE_CACHE_TTL_SECONDS = 5 * 60; // 5 minutes - avoid duplicate message processing
private readonly UPDATE_CACHE_TTL_SECONDS = 30 * 60; // 30 minutes - avoid duplicate status updates
public stateConnection: wa.StateConnection = { state: 'close' };
public phoneNumber: string;
@ -500,8 +499,8 @@ export class BaileysStartupService extends ChannelStartupService {
try {
// Use raw SQL to avoid JSON path issues
const webMessageInfo = (await this.prismaRepository.$queryRaw`
SELECT * FROM "Message"
WHERE "instanceId" = ${this.instanceId}
SELECT * FROM "Message"
WHERE "instanceId" = ${this.instanceId}
AND "key"->>'id' = ${key.id}
`) as proto.IWebMessageInfo[];
@ -1000,10 +999,6 @@ export class BaileysStartupService extends ChannelStartupService {
continue;
}
if (m.key.remoteJid?.includes('@lid') && (m.key as ExtendedIMessageKey).senderPn) {
m.key.remoteJid = (m.key as ExtendedIMessageKey).senderPn;
}
if (Long.isLong(m?.messageTimestamp)) {
m.messageTimestamp = m.messageTimestamp?.toNumber();
}
@ -1066,10 +1061,6 @@ export class BaileysStartupService extends ChannelStartupService {
) => {
try {
for (const received of messages) {
if (received.key.remoteJid?.includes('@lid') && (received.key as ExtendedMessageKey).senderPn) {
(received.key as ExtendedMessageKey).previousRemoteJid = received.key.remoteJid;
received.key.remoteJid = (received.key as ExtendedMessageKey).senderPn;
}
if (
received?.messageStubParameters?.some?.((param) =>
[
@ -1117,9 +1108,9 @@ export class BaileysStartupService extends ChannelStartupService {
await this.sendDataWebhook(Events.MESSAGES_EDITED, editedMessage);
const oldMessage = await this.getMessage(editedMessage.key, true);
if ((oldMessage as any)?.id) {
const editedMessageTimestamp = Long.isLong(editedMessage?.timestampMs)
? Math.floor(editedMessage.timestampMs.toNumber() / 1000)
: Math.floor((editedMessage.timestampMs as number) / 1000);
const editedMessageTimestamp = Long.isLong(received?.messageTimestamp)
? Math.floor(received?.messageTimestamp.toNumber())
: Math.floor(received?.messageTimestamp as number);
await this.prismaRepository.message.update({
where: { id: (oldMessage as any).id },
@ -1150,7 +1141,7 @@ export class BaileysStartupService extends ChannelStartupService {
continue;
}
await this.baileysCache.set(messageKey, true, 5 * 60);
await this.baileysCache.set(messageKey, true, this.MESSAGE_CACHE_TTL_SECONDS);
if (
(type !== 'notify' && type !== 'append') ||
@ -1270,7 +1261,7 @@ export class BaileysStartupService extends ChannelStartupService {
await this.updateMessagesReadedByTimestamp(remoteJid, timestamp);
}
await this.baileysCache.set(messageKey, true, 5 * 60);
await this.baileysCache.set(messageKey, true, this.MESSAGE_CACHE_TTL_SECONDS);
} else {
this.logger.info(`Update readed messages duplicated ignored [avoid deadlock]: ${messageKey}`);
}
@ -1358,12 +1349,10 @@ export class BaileysStartupService extends ChannelStartupService {
}
}
if (messageRaw.key.remoteJid?.includes('@lid') && messageRaw.key.remoteJidAlt) {
messageRaw.key.remoteJid = messageRaw.key.remoteJidAlt;
}
this.logger.log(messageRaw);
sendTelemetry(`received.message.${messageRaw.messageType ?? 'unknown'}`);
this.sendDataWebhook(Events.MESSAGES_UPSERT, messageRaw);
await chatbotController.emit({
@ -1437,9 +1426,7 @@ export class BaileysStartupService extends ChannelStartupService {
continue;
}
if (key.remoteJid?.includes('@lid') && key.remoteJidAlt) {
key.remoteJid = key.remoteJidAlt;
}
if (update.message !== null && update.status === undefined) continue;
const updateKey = `${this.instance.id}_${key.id}_${update.status}`;
@ -1480,7 +1467,7 @@ export class BaileysStartupService extends ChannelStartupService {
keyId: key.id,
remoteJid: key?.remoteJid,
fromMe: key.fromMe,
participant: key?.remoteJid,
participant: key?.participant,
status: status[update.status] ?? 'DELETED',
pollUpdates,
instanceId: this.instanceId,
@ -1491,14 +1478,18 @@ export class BaileysStartupService extends ChannelStartupService {
if (configDatabaseData.HISTORIC || configDatabaseData.NEW_MESSAGE) {
// Use raw SQL to avoid JSON path issues
const messages = (await this.prismaRepository.$queryRaw`
SELECT * FROM "Message"
WHERE "instanceId" = ${this.instanceId}
SELECT * FROM "Message"
WHERE "instanceId" = ${this.instanceId}
AND "key"->>'id' = ${key.id}
LIMIT 1
`) as any[];
findMessage = messages[0] || null;
if (findMessage) message.messageId = findMessage.id;
if (!findMessage?.id) {
this.logger.warn(`Original message not found for update. Skipping. Key: ${JSON.stringify(key)}`);
continue;
}
message.messageId = findMessage.id;
}
if (update.message === null && update.status === undefined) {
@ -1533,7 +1524,7 @@ export class BaileysStartupService extends ChannelStartupService {
if (status[update.status] === status[4]) {
this.logger.log(`Update as read in message.update ${remoteJid} - ${timestamp}`);
await this.updateMessagesReadedByTimestamp(remoteJid, timestamp);
await this.baileysCache.set(messageKey, true, 5 * 60);
await this.baileysCache.set(messageKey, true, this.MESSAGE_CACHE_TTL_SECONDS);
}
await this.prismaRepository.message.update({
@ -1591,12 +1582,66 @@ export class BaileysStartupService extends ChannelStartupService {
});
},
'group-participants.update': (participantsUpdate: {
'group-participants.update': async (participantsUpdate: {
id: string;
participants: string[];
action: ParticipantAction;
}) => {
this.sendDataWebhook(Events.GROUP_PARTICIPANTS_UPDATE, participantsUpdate);
// ENHANCEMENT: Adds participantsData field while maintaining backward compatibility
// MAINTAINS: participants: string[] (original JID strings)
// ADDS: participantsData: { jid: string, phoneNumber: string, name?: string, imgUrl?: string }[]
// This enables LID to phoneNumber conversion without breaking existing webhook consumers
// Helper to normalize participantId as phone number
const normalizePhoneNumber = (id: string): string => {
// Remove @lid, @s.whatsapp.net suffixes and extract just the number part
return id.split('@')[0];
};
try {
// Usa o mesmo método que o endpoint /group/participants
const groupParticipants = await this.findParticipants({ groupJid: participantsUpdate.id });
// Validação para garantir que temos dados válidos
if (!groupParticipants?.participants || !Array.isArray(groupParticipants.participants)) {
throw new Error('Invalid participant data received from findParticipants');
}
// Filtra apenas os participantes que estão no evento
const resolvedParticipants = participantsUpdate.participants.map((participantId) => {
const participantData = groupParticipants.participants.find((p) => p.id === participantId);
let phoneNumber: string;
if (participantData?.phoneNumber) {
phoneNumber = participantData.phoneNumber;
} else {
phoneNumber = normalizePhoneNumber(participantId);
}
return {
jid: participantId,
phoneNumber,
name: participantData?.name,
imgUrl: participantData?.imgUrl,
};
});
// Mantém formato original + adiciona dados resolvidos
const enhancedParticipantsUpdate = {
...participantsUpdate,
participants: participantsUpdate.participants, // Mantém array original de strings
// Adiciona dados resolvidos em campo separado
participantsData: resolvedParticipants,
};
this.sendDataWebhook(Events.GROUP_PARTICIPANTS_UPDATE, enhancedParticipantsUpdate);
} catch (error) {
this.logger.error(
`Failed to resolve participant data for GROUP_PARTICIPANTS_UPDATE webhook: ${error.message} | Group: ${participantsUpdate.id} | Participants: ${participantsUpdate.participants.length}`,
);
// Fallback - envia sem conversão
this.sendDataWebhook(Events.GROUP_PARTICIPANTS_UPDATE, participantsUpdate);
}
this.updateGroupMetadataCache(participantsUpdate.id);
},
@ -3367,18 +3412,13 @@ export class BaileysStartupService extends ChannelStartupService {
}
const numberJid = numberVerified?.jid || user.jid;
const lid =
typeof numberVerified?.lid === 'string'
? numberVerified.lid
: numberJid.includes('@lid')
? numberJid.split('@')[1]
: undefined;
return new OnWhatsAppDto(
numberJid,
!!numberVerified?.exists,
user.number,
contacts.find((c) => c.remoteJid === numberJid)?.pushName,
lid,
undefined,
);
}),
);
@ -3530,7 +3570,7 @@ export class BaileysStartupService extends ChannelStartupService {
keyId: messageId,
remoteJid: response.key.remoteJid,
fromMe: response.key.fromMe,
participant: response.key?.remoteJid,
participant: response.key?.participant,
status: 'DELETED',
instanceId: this.instanceId,
};
@ -3590,7 +3630,10 @@ export class BaileysStartupService extends ChannelStartupService {
}
}
if ('messageContextInfo' in msg.message && Object.keys(msg.message).length === 1) {
if (
Object.keys(msg.message).length === 1 &&
Object.prototype.hasOwnProperty.call(msg.message, 'messageContextInfo')
) {
throw 'The message is messageContextInfo';
}
@ -3965,7 +4008,7 @@ export class BaileysStartupService extends ChannelStartupService {
keyId: messageId,
remoteJid: messageSent.key.remoteJid,
fromMe: messageSent.key.fromMe,
participant: messageSent.key?.remoteJid,
participant: messageSent.key?.participant,
status: 'EDITED',
instanceId: this.instanceId,
};
@ -4461,7 +4504,7 @@ export class BaileysStartupService extends ChannelStartupService {
// Use raw SQL to avoid JSON path issues
const result = await this.prismaRepository.$executeRaw`
UPDATE "Message"
UPDATE "Message"
SET "status" = ${status[4]}
WHERE "instanceId" = ${this.instanceId}
AND "key"->>'remoteJid' = ${remoteJid}
@ -4486,7 +4529,7 @@ export class BaileysStartupService extends ChannelStartupService {
this.prismaRepository.chat.findFirst({ where: { remoteJid } }),
// Use raw SQL to avoid JSON path issues
this.prismaRepository.$queryRaw`
SELECT COUNT(*)::int as count FROM "Message"
SELECT COUNT(*)::int as count FROM "Message"
WHERE "instanceId" = ${this.instanceId}
AND "key"->>'remoteJid' = ${remoteJid}
AND ("key"->>'fromMe')::boolean = false
@ -4561,8 +4604,8 @@ export class BaileysStartupService extends ChannelStartupService {
return response;
}
public async baileysAssertSessions(jids: string[], force: boolean) {
const response = await this.client.assertSessions(jids, force);
public async baileysAssertSessions(jids: string[]) {
const response = await this.client.assertSessions(jids);
return response;
}
@ -4766,7 +4809,7 @@ export class BaileysStartupService extends ChannelStartupService {
{
OR: [
keyFilters?.remoteJid ? { key: { path: ['remoteJid'], equals: keyFilters?.remoteJid } } : {},
keyFilters?.senderPn ? { key: { path: ['senderPn'], equals: keyFilters?.senderPn } } : {},
keyFilters?.remoteJidAlt ? { key: { path: ['remoteJidAlt'], equals: keyFilters?.remoteJidAlt } } : {},
],
},
],
@ -4796,7 +4839,7 @@ export class BaileysStartupService extends ChannelStartupService {
{
OR: [
keyFilters?.remoteJid ? { key: { path: ['remoteJid'], equals: keyFilters?.remoteJid } } : {},
keyFilters?.senderPn ? { key: { path: ['senderPn'], equals: keyFilters?.senderPn } } : {},
keyFilters?.remoteJidAlt ? { key: { path: ['remoteJidAlt'], equals: keyFilters?.remoteJidAlt } } : {},
],
},
],

View File

@ -1,6 +1,5 @@
import { InstanceDto } from '@api/dto/instance.dto';
import { Options, Quoted, SendAudioDto, SendMediaDto, SendTextDto } from '@api/dto/sendMessage.dto';
import { ExtendedMessageKey } from '@api/integrations/channel/whatsapp/whatsapp.baileys.service';
import { ChatwootDto } from '@api/integrations/chatbot/chatwoot/dto/chatwoot.dto';
import { postgresClient } from '@api/integrations/chatbot/chatwoot/libs/postgres.client';
import { chatwootImport } from '@api/integrations/chatbot/chatwoot/utils/chatwoot-import-helper';
@ -24,7 +23,7 @@ import { Chatwoot as ChatwootModel, Contact as ContactModel, Message as MessageM
import i18next from '@utils/i18n';
import { sendTelemetry } from '@utils/sendTelemetry';
import axios from 'axios';
import { proto } from 'baileys';
import { proto, WAMessageKey } from 'baileys';
import dayjs from 'dayjs';
import FormData from 'form-data';
import { Jimp, JimpMime } from 'jimp';
@ -33,6 +32,8 @@ import mimeTypes from 'mime-types';
import path from 'path';
import { Readable } from 'stream';
const MIN_CONNECTION_NOTIFICATION_INTERVAL_MS = 30000; // 30 seconds
interface ChatwootMessage {
messageId?: number;
inboxId?: number;
@ -44,6 +45,25 @@ interface ChatwootMessage {
export class 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
private readonly LOCK_POLLING_DELAY_MS = 300; // Delay between lock status checks
private provider: any;
constructor(
@ -568,27 +588,29 @@ export class ChatwootService {
}
public async createConversation(instance: InstanceDto, body: any) {
const isLid = body.key.previousRemoteJid?.includes('@lid') && body.key.senderPn;
const remoteJid = body.key.remoteJid;
const isLid = body.key.addressingMode === 'lid' && body.key.remoteJidAlt;
const remoteJid = isLid ? body.key.remoteJidAlt : body.key.remoteJid;
const cacheKey = `${instance.instanceName}:createConversation-${remoteJid}`;
const lockKey = `${instance.instanceName}:lock:createConversation-${remoteJid}`;
const maxWaitTime = 5000; // 5 secounds
const maxWaitTime = 5000; // 5 seconds
const client = await this.clientCw(instance);
if (!client) return null;
try {
// Processa atualização de contatos já criados @lid
if (isLid && body.key.senderPn !== body.key.previousRemoteJid) {
if (isLid && body.key.remoteJidAlt !== body.key.remoteJid) {
const contact = await this.findContact(instance, body.key.remoteJid.split('@')[0]);
if (contact && contact.identifier !== body.key.senderPn) {
if (contact && contact.identifier !== body.key.remoteJidAlt) {
this.logger.verbose(
`Identifier needs update: (contact.identifier: ${contact.identifier}, body.key.remoteJid: ${body.key.remoteJid}, body.key.senderPn: ${body.key.senderPn}`,
`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, {
identifier: body.key.senderPn,
phone_number: `+${body.key.senderPn.split('@')[0]}`,
identifier: body.key.remoteJidAlt,
phone_number: `+${body.key.remoteJidAlt.split('@')[0]}`,
});
if (updateContact === null) {
const baseContact = await this.findContact(instance, body.key.senderPn.split('@')[0]);
const baseContact = await this.findContact(instance, body.key.remoteJidAlt.split('@')[0]);
if (baseContact) {
await this.mergeContacts(baseContact.id, contact.id);
this.logger.verbose(
@ -605,6 +627,22 @@ export class ChatwootService {
if (await this.cache.has(cacheKey)) {
const conversationId = (await this.cache.get(cacheKey)) as number;
this.logger.verbose(`Found conversation to: ${remoteJid}, conversation ID: ${conversationId}`);
let conversationExists: conversation | boolean;
try {
conversationExists = await client.conversations.get({
accountId: this.provider.accountId,
conversationId: conversationId,
});
this.logger.verbose(`Conversation exists: ${JSON.stringify(conversationExists)}`);
} catch (error) {
this.logger.error(`Error getting conversation: ${error}`);
conversationExists = false;
}
if (!conversationExists) {
this.logger.verbose('Conversation does not exist, re-calling createConversation');
this.cache.delete(cacheKey);
return await this.createConversation(instance, body);
}
return conversationId;
}
@ -617,7 +655,7 @@ export class ChatwootService {
this.logger.warn(`Timeout aguardando lock para ${remoteJid}`);
break;
}
await new Promise((res) => setTimeout(res, 300));
await new Promise((res) => setTimeout(res, this.LOCK_POLLING_DELAY_MS));
if (await this.cache.has(cacheKey)) {
const conversationId = (await this.cache.get(cacheKey)) as number;
this.logger.verbose(`Resolves creation of: ${remoteJid}, conversation ID: ${conversationId}`);
@ -639,9 +677,6 @@ export class ChatwootService {
return (await this.cache.get(cacheKey)) as number;
}
const client = await this.clientCw(instance);
if (!client) return null;
const isGroup = remoteJid.includes('@g.us');
const chatId = isGroup ? remoteJid : remoteJid.split('@')[0];
let nameContact = !body.key.fromMe ? body.pushName : chatId;
@ -769,7 +804,7 @@ export class ChatwootService {
if (inboxConversation) {
this.logger.verbose(`Returning existing conversation ID: ${inboxConversation.id}`);
this.cache.set(cacheKey, inboxConversation.id);
this.cache.set(cacheKey, inboxConversation.id, 8 * 3600);
return inboxConversation.id;
}
}
@ -802,7 +837,7 @@ export class ChatwootService {
}
this.logger.verbose(`New conversation created of ${remoteJid} with ID: ${conversation.id}`);
this.cache.set(cacheKey, conversation.id);
this.cache.set(cacheKey, conversation.id, 8 * 3600);
return conversation.id;
} finally {
await this.cache.delete(lockKey);
@ -1123,20 +1158,140 @@ export class ChatwootService {
public async sendAttachment(waInstance: any, number: string, media: any, caption?: string, options?: Options) {
try {
const parsedMedia = path.parse(decodeURIComponent(media));
let mimeType = mimeTypes.lookup(parsedMedia?.ext) || '';
let fileName = parsedMedia?.name + parsedMedia?.ext;
// Sempre baixar o arquivo do MinIO/S3 antes de enviar
// URLs presigned podem expirar, então convertemos para base64
let mediaBuffer: Buffer;
let mimeType: string;
let fileName: string;
if (!mimeType) {
const parts = media.split('/');
fileName = decodeURIComponent(parts[parts.length - 1]);
try {
this.logger.verbose(`Downloading media from: ${media}`);
// Tentar fazer download do arquivo com autenticação do Chatwoot
// maxRedirects: 0 para não seguir redirects automaticamente
const response = await axios.get(media, {
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';
switch (mimeType.split('/')[0]) {
@ -1154,10 +1309,12 @@ export class ChatwootService {
break;
}
// Para áudio, usar base64 com data URI
if (type === 'audio') {
const base64Audio = `data:${mimeType};base64,${mediaBuffer.toString('base64')}`;
const data: SendAudioDto = {
number: number,
audio: media,
audio: base64Audio,
delay: 1200,
quoted: options?.quoted,
};
@ -1169,8 +1326,12 @@ export class ChatwootService {
return messageSent;
}
const documentExtensions = ['.gif', '.svg', '.tiff', '.tif'];
if (type === 'image' && parsedMedia && documentExtensions.includes(parsedMedia?.ext)) {
// 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 parsedExt = path.parse(fileName)?.ext;
if (type === 'image' && parsedExt && documentExtensions.includes(parsedExt)) {
type = 'document';
}
@ -1178,7 +1339,7 @@ export class ChatwootService {
number: number,
mediatype: type as any,
fileName: fileName,
media: media,
media: base64Media, // Base64 puro, sem prefixo
delay: 1200,
quoted: options?.quoted,
};
@ -1194,6 +1355,7 @@ export class ChatwootService {
return messageSent;
} catch (error) {
this.logger.error(error);
throw error; // Re-throw para que o erro seja tratado pelo caller
}
}
@ -1233,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) {
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);
@ -1254,6 +1494,39 @@ export class ChatwootService {
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 (
!body?.conversation ||
body.private ||
@ -1285,7 +1558,7 @@ export class ChatwootService {
});
if (message) {
const key = message.key as ExtendedMessageKey;
const key = message.key as WAMessageKey;
await waInstance?.client.sendMessage(key.remoteJid, { delete: key });
@ -1370,7 +1643,10 @@ export class ChatwootService {
}
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' };
}
@ -1394,40 +1670,58 @@ export class ChatwootService {
for (const message of body.conversation.messages) {
if (message.attachments && message.attachments.length > 0) {
for (const attachment of message.attachments) {
if (!messageReceived) {
formatText = null;
// Processa anexos de forma assíncrona para não bloquear o webhook
const processAttachments = async () => {
for (const attachment of message.attachments) {
if (!messageReceived) {
formatText = null;
}
const options: Options = {
quoted: await this.getQuotedMessage(body, instance),
};
try {
const messageSent = await this.sendAttachment(
waInstance,
chatId,
attachment.data_url,
formatText,
options,
);
if (!messageSent && body.conversation?.id) {
this.onSendMessageError(instance, body.conversation?.id);
}
if (messageSent) {
await this.updateChatwootMessageId(
{
...messageSent,
owner: instance.instanceName,
},
{
messageId: body.id,
inboxId: body.inbox?.id,
conversationId: body.conversation?.id,
contactInboxSourceId: body.conversation?.contact_inbox?.source_id,
},
instance,
);
}
} catch (error) {
this.logger.error(error);
if (body.conversation?.id) {
this.onSendMessageError(instance, body.conversation?.id, error);
}
}
}
};
const options: Options = {
quoted: await this.getQuotedMessage(body, instance),
};
const messageSent = await this.sendAttachment(
waInstance,
chatId,
attachment.data_url,
formatText,
options,
);
if (!messageSent && body.conversation?.id) {
this.onSendMessageError(instance, body.conversation?.id);
}
await this.updateChatwootMessageId(
{
...messageSent,
owner: instance.instanceName,
},
{
messageId: body.id,
inboxId: body.inbox?.id,
conversationId: body.conversation?.id,
contactInboxSourceId: body.conversation?.contact_inbox?.source_id,
},
instance,
);
}
// Executa em background sem bloquear
processAttachments().catch((error) => {
this.logger.error(error);
});
} else {
const data: SendTextDto = {
number: chatId,
@ -1450,10 +1744,7 @@ export class ChatwootService {
}
await this.updateChatwootMessageId(
{
...messageSent,
instanceId: instance.instanceId,
},
messageSent, // Já tem instanceId
{
messageId: body.id,
inboxId: body.inbox?.id,
@ -1483,7 +1774,7 @@ export class ChatwootService {
},
});
if (lastMessage && !lastMessage.chatwootIsRead) {
const key = lastMessage.key as ExtendedMessageKey;
const key = lastMessage.key as WAMessageKey;
waInstance?.markMessageAsRead({
readMessages: [
@ -1541,14 +1832,63 @@ export class ChatwootService {
chatwootMessageIds: ChatwootMessage,
instance: InstanceDto,
) {
const key = message.key as ExtendedMessageKey;
const key = message.key as WAMessageKey;
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;
}
// Use raw SQL to avoid JSON path issues
await this.prismaRepository.$executeRaw`
const result = await this.prismaRepository.$executeRaw`
UPDATE "Message"
SET
"chatwootMessageId" = ${chatwootMessageIds.messageId},
@ -1556,10 +1896,12 @@ export class ChatwootService {
"chatwootInboxId" = ${chatwootMessageIds.inboxId},
"chatwootContactInboxSourceId" = ${chatwootMessageIds.contactInboxSourceId},
"chatwootIsRead" = ${chatwootMessageIds.isRead || false}
WHERE "instanceId" = ${instance.instanceId}
WHERE "instanceId" = ${instanceId}
AND "key"->>'id' = ${key.id}
`;
this.logger.verbose(`Update result: ${result} rows affected`);
if (this.isImportHistoryAvailable()) {
chatwootImport.updateMessageSourceID(chatwootMessageIds.messageId, key.id);
}
@ -1609,7 +1951,7 @@ export class ChatwootService {
},
});
const key = message?.key as ExtendedMessageKey;
const key = message?.key as WAMessageKey;
if (message && key?.id) {
return {
@ -1913,6 +2255,7 @@ export class ChatwootService {
}
if (event === 'messages.upsert' || event === 'send.message') {
this.logger.info(`[${event}] New message received - Instance: ${JSON.stringify(body, null, 2)}`);
if (body.key.remoteJid === 'status@broadcast') {
return;
}
@ -2235,9 +2578,8 @@ export class ChatwootService {
}
if (event === 'messages.edit' || event === 'send.message.update') {
const editedText = `${
body?.editedMessage?.conversation || body?.editedMessage?.extendedTextMessage?.text
}\n\n_\`${i18next.t('cw.message.edited')}.\`_`;
const editedMessageContent =
body?.editedMessage?.conversation || body?.editedMessage?.extendedTextMessage?.text;
const message = await this.getMessageByKeyId(instance, body?.key?.id);
if (!message) {
@ -2245,11 +2587,14 @@ export class ChatwootService {
return;
}
const key = message.key as ExtendedMessageKey;
const key = message.key as WAMessageKey;
const messageType = key?.fromMe ? 'outgoing' : 'incoming';
if (message && message.chatwootConversationId) {
if (message && message.chatwootConversationId && message.chatwootMessageId) {
// Criar nova mensagem com formato: "Mensagem editada:\n\nteste1"
const editedText = `\n\n\`${i18next.t('cw.message.edited')}:\`\n\n${editedMessageContent}`;
const send = await this.createMessage(
instance,
message.chatwootConversationId,
@ -2327,15 +2672,30 @@ export class ChatwootService {
await this.createBotMessage(instance, msgStatus, 'incoming');
}
if (event === 'connection.update') {
if (body.status === 'open') {
// if we have qrcode count then we understand that a new connection was established
if (this.waMonitor.waInstances[instance.instanceName].qrCode.count > 0) {
const msgConnection = i18next.t('cw.inbox.connected');
await this.createBotMessage(instance, msgConnection, 'incoming');
this.waMonitor.waInstances[instance.instanceName].qrCode.count = 0;
chatwootImport.clearAll(instance);
}
if (event === 'connection.update' && body.status === 'open') {
const waInstance = this.waMonitor.waInstances[instance.instanceName];
if (!waInstance) return;
const now = Date.now();
const timeSinceLastNotification = now - (waInstance.lastConnectionNotification || 0);
// Se a conexão foi estabelecida via QR code, notifica imediatamente.
if (waInstance.qrCode && waInstance.qrCode.count > 0) {
const msgConnection = i18next.t('cw.inbox.connected');
await this.createBotMessage(instance, msgConnection, 'incoming');
waInstance.qrCode.count = 0;
waInstance.lastConnectionNotification = now;
chatwootImport.clearAll(instance);
}
// Se não foi via QR code, verifica o throttling.
else if (timeSinceLastNotification >= MIN_CONNECTION_NOTIFICATION_INTERVAL_MS) {
const msgConnection = i18next.t('cw.inbox.connected');
await this.createBotMessage(instance, msgConnection, 'incoming');
waInstance.lastConnectionNotification = now;
} else {
this.logger.warn(
`Connection notification skipped for ${instance.instanceName} - too frequent (${timeSinceLastNotification}ms since last)`,
);
}
}

View File

@ -112,12 +112,19 @@ class ChatwootImport {
const bindInsert = [provider.accountId];
for (const contact of contactsChunk) {
bindInsert.push(contact.pushName);
const isGroup = this.isIgnorePhoneNumber(contact.remoteJid);
const contactName = isGroup ? `${contact.pushName} (GROUP)` : contact.pushName;
bindInsert.push(contactName);
const bindName = `$${bindInsert.length}`;
bindInsert.push(`+${contact.remoteJid.split('@')[0]}`);
const bindPhoneNumber = `$${bindInsert.length}`;
let bindPhoneNumber: string;
if (!isGroup) {
bindInsert.push(`+${contact.remoteJid.split('@')[0]}`);
bindPhoneNumber = `$${bindInsert.length}`;
} else {
bindPhoneNumber = 'NULL';
}
bindInsert.push(contact.remoteJid);
const bindIdentifier = `$${bindInsert.length}`;

View File

@ -826,7 +826,7 @@ export class ChannelStartupService {
const msg = message.message;
// Se só tem messageContextInfo, não é mídia válida
if (Object.keys(msg).length === 1 && 'messageContextInfo' in msg) {
if (Object.keys(msg).length === 1 && Object.prototype.hasOwnProperty.call(msg, 'messageContextInfo')) {
return false;
}

View File

@ -3,8 +3,6 @@ import fs from 'fs';
import i18next from 'i18next';
import path from 'path';
const __dirname = path.resolve(process.cwd(), 'src', 'utils');
const languages = ['en', 'pt-BR', 'es'];
const translationsPath = path.join(__dirname, 'translations');
const configService: ConfigService = new ConfigService();