Compare commits

..

7 Commits

Author SHA1 Message Date
Davidson Gomes
6efa879081 chore: increase token length in Instance model across MySQL, PostgreSQL, and PSQL Bouncer schemas
Some checks failed
Check Code Quality / check-lint-and-build (push) Has been cancelled
Build Docker image / Build and Deploy (push) Has been cancelled
Security Scan / CodeQL Analysis (javascript) (push) Has been cancelled
Security Scan / Dependency Review (push) Has been cancelled
2025-12-16 14:32:26 -03:00
Davidson Gomes
2534ec2307 Merge branch 'develop' of github.com:EvolutionAPI/evolution-api into develop 2025-12-16 14:31:24 -03:00
Davidson Gomes
933a28de26 feat(baileys): enhance logout process and connection handling
- Introduced a flag to prevent reconnection during instance deletion.
- Improved logging for connection updates and errors during logout.
- Added a delay before reconnection attempts to avoid rapid loops.
- Enhanced webhook headers for better tracking and debugging.
- Updated configuration to support manual Baileys version setting.
2025-12-16 14:18:05 -03:00
Davidson Gomes
b1b07b7e7f Merge pull request #2321 from Vitordotpy/fix/remotejid-normalization-and-cache-race
fix: normalize remoteJid in message updates and handle race condition in contact cache
2025-12-16 12:49:58 -03:00
Vitordotpy
bb831d590f refactor: optimize retry loop and robustify cache error handling 2025-12-16 12:38:47 -03:00
Vitordotpy
cb41e65e29 fix: enhance logging for missing original messages during updates 2025-12-16 11:32:53 -03:00
Vitordotpy
52a8d9ea71 fix: normalize remoteJid in message updates and handle race condition in contact cache 2025-12-16 11:00:11 -03:00
10 changed files with 171 additions and 25 deletions

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE `Instance` MODIFY `token` VARCHAR(500);

View File

@@ -70,7 +70,7 @@ model Instance {
integration String? @db.VarChar(100) integration String? @db.VarChar(100)
number String? @db.VarChar(100) number String? @db.VarChar(100)
businessId String? @db.VarChar(100) businessId String? @db.VarChar(100)
token String? @db.VarChar(255) token String? @db.VarChar(500)
clientName String? @db.VarChar(100) clientName String? @db.VarChar(100)
disconnectionReasonCode Int? @db.Int disconnectionReasonCode Int? @db.Int
disconnectionObject Json? @db.Json disconnectionObject Json? @db.Json

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "Instance" ALTER COLUMN "token" TYPE VARCHAR(500);

View File

@@ -70,7 +70,7 @@ model Instance {
integration String? @db.VarChar(100) integration String? @db.VarChar(100)
number String? @db.VarChar(100) number String? @db.VarChar(100)
businessId String? @db.VarChar(100) businessId String? @db.VarChar(100)
token String? @db.VarChar(255) token String? @db.VarChar(500)
clientName String? @db.VarChar(100) clientName String? @db.VarChar(100)
disconnectionReasonCode Int? @db.Integer disconnectionReasonCode Int? @db.Integer
disconnectionObject Json? @db.JsonB disconnectionObject Json? @db.JsonB

View File

@@ -71,7 +71,7 @@ model Instance {
integration String? @db.VarChar(100) integration String? @db.VarChar(100)
number String? @db.VarChar(100) number String? @db.VarChar(100)
businessId String? @db.VarChar(100) businessId String? @db.VarChar(100)
token String? @db.VarChar(255) token String? @db.VarChar(500)
clientName String? @db.VarChar(100) clientName String? @db.VarChar(100)
disconnectionReasonCode Int? @db.Integer disconnectionReasonCode Int? @db.Integer
disconnectionObject Json? @db.JsonB disconnectionObject Json? @db.JsonB

View File

@@ -249,6 +249,7 @@ export class BaileysStartupService extends ChannelStartupService {
private readonly msgRetryCounterCache: CacheStore = new NodeCache(); private readonly msgRetryCounterCache: CacheStore = new NodeCache();
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 isDeleting = false; // Flag to prevent reconnection during deletion
private logBaileys = this.configService.get<Log>('LOG').BAILEYS; private logBaileys = this.configService.get<Log>('LOG').BAILEYS;
private eventProcessingQueue: Promise<void> = Promise.resolve(); private eventProcessingQueue: Promise<void> = Promise.resolve();
@@ -265,10 +266,27 @@ export class BaileysStartupService extends ChannelStartupService {
} }
public async logoutInstance() { public async logoutInstance() {
this.messageProcessor.onDestroy(); // Mark instance as deleting to prevent reconnection attempts
await this.client?.logout('Log out instance: ' + this.instanceName); this.isDeleting = true;
this.endSession = true;
this.client?.ws?.close(); this.messageProcessor.onDestroy();
if (this.client) {
try {
await this.client.logout('Log out instance: ' + this.instanceName);
} catch (error) {
this.logger.error({ message: 'Error during logout', error });
}
// Improved socket cleanup
try {
this.client.ws?.close();
this.client.end(new Error('Instance logout'));
} catch (error) {
this.logger.error({ message: 'Error during socket cleanup', error });
}
}
const db = this.configService.get<Database>('DATABASE'); const db = this.configService.get<Database>('DATABASE');
const cache = this.configService.get<CacheConf>('CACHE'); const cache = this.configService.get<CacheConf>('CACHE');
@@ -332,6 +350,18 @@ export class BaileysStartupService extends ChannelStartupService {
} }
private async connectionUpdate({ qr, connection, lastDisconnect }: Partial<ConnectionState>) { private async connectionUpdate({ qr, connection, lastDisconnect }: Partial<ConnectionState>) {
// Enhanced logging for connection updates
const statusCode = (lastDisconnect?.error as Boom)?.output?.statusCode;
this.logger.info({
message: 'Connection update received',
connection,
hasQr: !!qr,
statusCode,
instanceName: this.instance.name,
isDeleting: this.isDeleting,
endSession: this.endSession,
});
if (qr) { if (qr) {
if (this.instance.qrcode.count === this.configService.get<QrCode>('QRCODE').LIMIT) { if (this.instance.qrcode.count === this.configService.get<QrCode>('QRCODE').LIMIT) {
this.sendDataWebhook(Events.QRCODE_UPDATED, { this.sendDataWebhook(Events.QRCODE_UPDATED, {
@@ -424,11 +454,29 @@ export class BaileysStartupService extends ChannelStartupService {
} }
if (connection === 'close') { if (connection === 'close') {
// Check if instance is being deleted or session is ending
if (this.isDeleting || this.endSession) {
this.logger.info('Instance is being deleted/ended, skipping reconnection attempt');
return;
}
const statusCode = (lastDisconnect?.error as Boom)?.output?.statusCode; const statusCode = (lastDisconnect?.error as Boom)?.output?.statusCode;
const codesToNotReconnect = [DisconnectReason.loggedOut, DisconnectReason.forbidden, 402, 406]; const codesToNotReconnect = [DisconnectReason.loggedOut, DisconnectReason.forbidden, 402, 406];
const shouldReconnect = !codesToNotReconnect.includes(statusCode); const shouldReconnect = !codesToNotReconnect.includes(statusCode);
this.logger.info({
message: 'Connection closed, evaluating reconnection',
statusCode,
shouldReconnect,
instanceName: this.instance.name,
});
if (shouldReconnect) { if (shouldReconnect) {
// Add 3 second delay before reconnection to prevent rapid reconnection loops
this.logger.info('Reconnecting in 3 seconds...');
setTimeout(async () => {
await this.connectToWhatsapp(this.phoneNumber); await this.connectToWhatsapp(this.phoneNumber);
}, 3000);
} else { } else {
this.sendDataWebhook(Events.STATUS_INSTANCE, { this.sendDataWebhook(Events.STATUS_INSTANCE, {
instance: this.instance.name, instance: this.instance.name,
@@ -591,10 +639,11 @@ export class BaileysStartupService extends ChannelStartupService {
this.logger.info(`Browser: ${browser}`); this.logger.info(`Browser: ${browser}`);
} }
// Fetch latest WhatsApp Web version automatically
const baileysVersion = await fetchLatestWaWebVersion({}); const baileysVersion = await fetchLatestWaWebVersion({});
const version = baileysVersion.version; const version = baileysVersion.version;
const log = `Baileys version: ${version.join('.')}`;
const log = `Baileys version: ${version.join('.')}`;
this.logger.info(log); this.logger.info(log);
this.logger.info(`Group Ignore: ${this.localSettings.groupsIgnore}`); this.logger.info(`Group Ignore: ${this.localSettings.groupsIgnore}`);
@@ -602,7 +651,7 @@ export class BaileysStartupService extends ChannelStartupService {
let options; let options;
if (this.localProxy?.enabled) { if (this.localProxy?.enabled) {
this.logger.info('Proxy enabled: ' + this.localProxy?.host); this.logger.verbose('Proxy enabled');
if (this.localProxy?.host?.includes('proxyscrape')) { if (this.localProxy?.host?.includes('proxyscrape')) {
try { try {
@@ -611,9 +660,10 @@ 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];
this.logger.info('Proxy url: ' + proxyUrl);
options = { agent: makeProxyAgent(proxyUrl), fetchAgent: makeProxyAgentUndici(proxyUrl) }; options = { agent: makeProxyAgent(proxyUrl), fetchAgent: makeProxyAgentUndici(proxyUrl) };
} catch { } catch (error) {
this.localProxy.enabled = false; this.logger.error(error);
} }
} else { } else {
options = { options = {
@@ -1565,8 +1615,15 @@ export class BaileysStartupService extends ChannelStartupService {
for await (const { key, update } of args) { for await (const { key, update } of args) {
const keyAny = key as any; const keyAny = key as any;
const normalizedRemoteJid = keyAny.remoteJid?.replace(/:.*$/, ''); if (keyAny.remoteJid) {
const normalizedParticipant = keyAny.participant?.replace(/:.*$/, ''); keyAny.remoteJid = keyAny.remoteJid.replace(/:.*$/, '');
}
if (keyAny.participant) {
keyAny.participant = keyAny.participant.replace(/:.*$/, '');
}
const normalizedRemoteJid = keyAny.remoteJid;
const normalizedParticipant = keyAny.participant;
if (settings?.groupsIgnore && normalizedRemoteJid?.includes('@g.us')) { if (settings?.groupsIgnore && normalizedRemoteJid?.includes('@g.us')) {
continue; continue;
@@ -1644,6 +1701,11 @@ export class BaileysStartupService extends ChannelStartupService {
const searchId = originalMessageId || key.id; const searchId = originalMessageId || key.id;
let retries = 0;
const maxRetries = 3;
const retryDelay = 500; // 500ms delay to avoid blocking for too long
while (retries < maxRetries) {
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}
@@ -1652,10 +1714,35 @@ export class BaileysStartupService extends ChannelStartupService {
`) as any[]; `) as any[];
findMessage = messages[0] || null; findMessage = messages[0] || null;
if (findMessage?.id) {
break;
}
retries++;
if (retries < maxRetries) {
await delay(retryDelay);
}
}
if (!findMessage?.id) { if (!findMessage?.id) {
this.logger.warn(`Original message not found for update. Skipping. Key: ${JSON.stringify(key)}`); this.logger.verbose(
`Original message not found for update after ${maxRetries} retries. Skipping. This is expected for protocol messages or ephemeral events not saved to the database. Key: ${JSON.stringify(key)}`,
);
continue; continue;
} }
// Sync the incoming key.remoteJid with the stored one.
// This mutation is safe and necessary because Baileys events might use LIDs while we store Phone JIDs (or vice versa).
// Normalizing ensuring downstream logic uses the identifier that exists in our database.
if (findMessage?.key?.remoteJid && key.remoteJid !== findMessage.key.remoteJid) {
key.remoteJid = findMessage.key.remoteJid;
}
if (findMessage?.key?.remoteJid && findMessage.key.remoteJid !== key.remoteJid) {
this.logger.verbose(
`Updating key.remoteJid from ${key.remoteJid} to ${findMessage.key.remoteJid} based on stored message`,
);
key.remoteJid = findMessage.key.remoteJid;
}
message.messageId = findMessage.id; message.messageId = findMessage.id;
} }

View File

@@ -124,9 +124,20 @@ export class WebhookController extends EventController implements EventControlle
try { try {
if (instance?.enabled && regex.test(instance.url)) { if (instance?.enabled && regex.test(instance.url)) {
// Add custom headers for better webhook tracking and debugging
const enhancedHeaders = {
...webhookHeaders,
'Content-Type': 'application/json',
'X-Instance-ID': this.monitor.waInstances[instanceName].instanceId,
'X-Instance-Name': instanceName,
'X-Event-Type': event,
'X-Timestamp': Date.now().toString(),
'User-Agent': 'EvolutionAPI-Webhook/2.3.7',
};
const httpService = axios.create({ const httpService = axios.create({
baseURL, baseURL,
headers: webhookHeaders as Record<string, string> | undefined, headers: enhancedHeaders as Record<string, string>,
timeout: webhookConfig.REQUEST?.TIMEOUT_MS ?? 30000, timeout: webhookConfig.REQUEST?.TIMEOUT_MS ?? 30000,
}); });

View File

@@ -313,6 +313,7 @@ export type Webhook = {
}; };
export type Pusher = { ENABLED: boolean; GLOBAL?: GlobalPusher; EVENTS: EventsPusher }; export type Pusher = { ENABLED: boolean; GLOBAL?: GlobalPusher; EVENTS: EventsPusher };
export type ConfigSessionPhone = { CLIENT: string; NAME: string }; export type ConfigSessionPhone = { CLIENT: string; NAME: string };
export type Baileys = { VERSION?: string };
export type QrCode = { LIMIT: number; COLOR: string }; export type QrCode = { LIMIT: number; COLOR: string };
export type Typebot = { ENABLED: boolean; API_VERSION: string; SEND_MEDIA_BASE64: boolean }; export type Typebot = { ENABLED: boolean; API_VERSION: string; SEND_MEDIA_BASE64: boolean };
export type Chatwoot = { export type Chatwoot = {
@@ -410,6 +411,7 @@ export interface Env {
WEBHOOK: Webhook; WEBHOOK: Webhook;
PUSHER: Pusher; PUSHER: Pusher;
CONFIG_SESSION_PHONE: ConfigSessionPhone; CONFIG_SESSION_PHONE: ConfigSessionPhone;
BAILEYS: Baileys;
QRCODE: QrCode; QRCODE: QrCode;
TYPEBOT: Typebot; TYPEBOT: Typebot;
CHATWOOT: Chatwoot; CHATWOOT: Chatwoot;
@@ -800,6 +802,9 @@ export class ConfigService {
CLIENT: process.env?.CONFIG_SESSION_PHONE_CLIENT || 'Evolution API', CLIENT: process.env?.CONFIG_SESSION_PHONE_CLIENT || 'Evolution API',
NAME: process.env?.CONFIG_SESSION_PHONE_NAME || 'Chrome', NAME: process.env?.CONFIG_SESSION_PHONE_NAME || 'Chrome',
}, },
BAILEYS: {
VERSION: process.env?.CONFIG_BAILEYS_VERSION,
},
QRCODE: { QRCODE: {
LIMIT: Number.parseInt(process.env.QRCODE_LIMIT) || 30, LIMIT: Number.parseInt(process.env.QRCODE_LIMIT) || 30,
COLOR: process.env.QRCODE_COLOR || '#198754', COLOR: process.env.QRCODE_COLOR || '#198754',

View File

@@ -1,7 +1,24 @@
import axios, { AxiosRequestConfig } from 'axios'; import axios, { AxiosRequestConfig } from 'axios';
import { fetchLatestBaileysVersion, WAVersion } from 'baileys'; import { fetchLatestBaileysVersion, WAVersion } from 'baileys';
import { Baileys, configService } from '../config/env.config';
export const fetchLatestWaWebVersion = async (options: AxiosRequestConfig<{}>) => { export const fetchLatestWaWebVersion = async (options: AxiosRequestConfig<{}>) => {
// Check if manual version is set via configuration
const baileysConfig = configService.get<Baileys>('BAILEYS');
const manualVersion = baileysConfig?.VERSION;
if (manualVersion) {
const versionParts = manualVersion.split('.').map(Number);
if (versionParts.length === 3 && !versionParts.some(isNaN)) {
return {
version: versionParts as WAVersion,
isLatest: false,
isManual: true,
};
}
}
try { try {
const { data } = await axios.get('https://web.whatsapp.com/sw.js', { const { data } = await axios.get('https://web.whatsapp.com/sw.js', {
...options, ...options,

View File

@@ -1,6 +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 { Logger } from '@config/logger.config';
import { Prisma } from '@prisma/client';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
const logger = new Logger('OnWhatsappCache'); const logger = new Logger('OnWhatsappCache');
@@ -164,9 +165,28 @@ export async function saveOnWhatsappCache(data: ISaveOnWhatsappCacheParams[]) {
logger.verbose( logger.verbose(
`[saveOnWhatsappCache] Register does not exist, creating: remoteJid=${remoteJid}, jidOptions=${dataPayload.jidOptions}, lid=${dataPayload.lid}`, `[saveOnWhatsappCache] Register does not exist, creating: remoteJid=${remoteJid}, jidOptions=${dataPayload.jidOptions}, lid=${dataPayload.lid}`,
); );
try {
await prismaRepository.isOnWhatsapp.create({ await prismaRepository.isOnWhatsapp.create({
data: dataPayload, data: dataPayload,
}); });
} catch (error: any) {
// Check for unique constraint violation (Prisma error code P2002)
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === 'P2002' &&
(error.meta?.target as string[])?.includes('remoteJid')
) {
logger.verbose(
`[saveOnWhatsappCache] Race condition detected for ${remoteJid}, updating existing record instead.`,
);
await prismaRepository.isOnWhatsapp.update({
where: { remoteJid: remoteJid },
data: dataPayload,
});
} else {
throw error;
}
}
} }
} catch (e) { } catch (e) {
// Loga o erro mas não para a execução dos outros promises // Loga o erro mas não para a execução dos outros promises