Skip to content

Commit a2146e9

Browse files
committed
fix: trata DisconnectReason.connectionReplaced e evita reconexoes concorrentes
Instancias especificas desconectavam diariamente sem intervencao do usuario. Causa raiz: DisconnectReason.connectionReplaced (440) nao estava na lista de codigos que interrompem a reconexao automatica, entao o socket tentava reconectar com credenciais ja invalidadas pela troca de sessao, gerando um ciclo close -> reconnect -> close. Alem disso, connectToWhatsapp/createClient nao tinham nenhum lock contra chamadas concorrentes: se uma reconexao manual (ex. via Chatwoot) disparasse enquanto a reconexao automatica do listener de 'close' ja estava em andamento, dois sockets Baileys podiam competir pela mesma sessao - um dos gatilhos conhecidos do proprio connectionReplaced. - Adiciona connectionReplaced a lista de codigos que nao devem reconectar automaticamente, com log explicito exigindo novo QR. - Adiciona lock de instancia (isConnecting) em createClient para impedir sockets concorrentes na mesma sessao.
1 parent fa09d37 commit a2146e9

1 file changed

Lines changed: 161 additions & 117 deletions

File tree

src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts

Lines changed: 161 additions & 117 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,11 @@ export class BaileysStartupService extends ChannelStartupService {
251251
private endSession = false;
252252
private logBaileys = this.configService.get<Log>('LOG').BAILEYS;
253253
private eventProcessingQueue: Promise<void> = Promise.resolve();
254+
// Prevents concurrent connectToWhatsapp/createClient calls from racing on the
255+
// same session (e.g. Chatwoot-triggered manual reconnect firing while the
256+
// automatic reconnect from a 'close' event is already in flight), which can
257+
// itself cause Baileys to emit DisconnectReason.connectionReplaced (440).
258+
private isConnecting = false;
254259

255260
// Cache TTL constants (in seconds)
256261
private readonly MESSAGE_CACHE_TTL_SECONDS = 5 * 60; // 5 minutes - avoid duplicate message processing
@@ -425,8 +430,29 @@ export class BaileysStartupService extends ChannelStartupService {
425430

426431
if (connection === 'close') {
427432
const statusCode = (lastDisconnect?.error as Boom)?.output?.statusCode;
428-
const codesToNotReconnect = [DisconnectReason.loggedOut, DisconnectReason.forbidden, 402, 406];
433+
// connectionReplaced (440) means the session was taken over elsewhere (another
434+
// device/tab, or a concurrent connection attempt on our own side). Auto-reconnecting
435+
// here would retry with credentials that the takeover may have already invalidated,
436+
// producing a close -> reconnect -> close loop. Treat it like the other terminal
437+
// codes: stop, surface a clear signal, and require an explicit new connection/QR scan.
438+
const codesToNotReconnect = [
439+
DisconnectReason.loggedOut,
440+
DisconnectReason.forbidden,
441+
DisconnectReason.connectionReplaced,
442+
402,
443+
406,
444+
];
429445
const shouldReconnect = !codesToNotReconnect.includes(statusCode);
446+
447+
if (statusCode === DisconnectReason.connectionReplaced) {
448+
this.logger.warn(
449+
`Instance ${this.instance.name}: connection replaced (DisconnectReason.connectionReplaced/440). ` +
450+
'This session was opened elsewhere (another device/tab, or a concurrent connection attempt) ' +
451+
'and will NOT be auto-reconnected with the current credentials. A new QR Code scan (or explicit ' +
452+
'reconnect) will be required.',
453+
);
454+
}
455+
430456
if (shouldReconnect) {
431457
await this.connectToWhatsapp(this.phoneNumber);
432458
} else {
@@ -574,150 +600,168 @@ export class BaileysStartupService extends ChannelStartupService {
574600
}
575601

576602
private async createClient(number?: string): Promise<WASocket> {
577-
this.instance.authState = await this.defineAuthState();
603+
// Guard against concurrent connection attempts on the same instance (e.g. a
604+
// manual reconnect from Chatwoot/the API firing while the automatic reconnect
605+
// triggered by a 'close' event is still in flight). Two Baileys sockets racing
606+
// on the same session is a known trigger for DisconnectReason.connectionReplaced.
607+
if (this.isConnecting) {
608+
this.logger.warn(
609+
`Instance ${this.instance.name}: a connection attempt is already in progress; ` +
610+
'skipping this concurrent createClient call to avoid racing sockets on the same session.',
611+
);
612+
return this.client;
613+
}
578614

579-
const session = this.configService.get<ConfigSessionPhone>('CONFIG_SESSION_PHONE');
615+
this.isConnecting = true;
616+
try {
617+
this.instance.authState = await this.defineAuthState();
580618

581-
let browserOptions = {};
619+
const session = this.configService.get<ConfigSessionPhone>('CONFIG_SESSION_PHONE');
582620

583-
if (number || this.phoneNumber) {
584-
this.phoneNumber = number;
621+
let browserOptions = {};
585622

586-
this.logger.info(`Phone number: ${number}`);
587-
} else {
588-
const browser: WABrowserDescription = [session.CLIENT, session.NAME, release()];
589-
browserOptions = { browser };
623+
if (number || this.phoneNumber) {
624+
this.phoneNumber = number;
590625

591-
this.logger.info(`Browser: ${browser}`);
592-
}
626+
this.logger.info(`Phone number: ${number}`);
627+
} else {
628+
const browser: WABrowserDescription = [session.CLIENT, session.NAME, release()];
629+
browserOptions = { browser };
593630

594-
const baileysVersion = await fetchLatestWaWebVersion({});
595-
const version = baileysVersion.version;
596-
const log = `Baileys version: ${version.join('.')}`;
631+
this.logger.info(`Browser: ${browser}`);
632+
}
597633

598-
this.logger.info(log);
634+
const baileysVersion = await fetchLatestWaWebVersion({});
635+
const version = baileysVersion.version;
636+
const log = `Baileys version: ${version.join('.')}`;
599637

600-
this.logger.info(`Group Ignore: ${this.localSettings.groupsIgnore}`);
638+
this.logger.info(log);
601639

602-
let options;
640+
this.logger.info(`Group Ignore: ${this.localSettings.groupsIgnore}`);
603641

604-
if (this.localProxy?.enabled) {
605-
this.logger.info('Proxy enabled: ' + this.localProxy?.host);
642+
let options;
606643

607-
if (this.localProxy?.host?.includes('proxyscrape')) {
608-
try {
609-
const response = await axios.get(this.localProxy?.host);
610-
const text = response.data;
611-
const proxyUrls = text.split('\r\n');
612-
const rand = Math.floor(Math.random() * Math.floor(proxyUrls.length));
613-
const proxyUrl = 'http://' + proxyUrls[rand];
614-
options = { agent: makeProxyAgent(proxyUrl), fetchAgent: makeProxyAgentUndici(proxyUrl) };
615-
} catch {
616-
this.localProxy.enabled = false;
644+
if (this.localProxy?.enabled) {
645+
this.logger.info('Proxy enabled: ' + this.localProxy?.host);
646+
647+
if (this.localProxy?.host?.includes('proxyscrape')) {
648+
try {
649+
const response = await axios.get(this.localProxy?.host);
650+
const text = response.data;
651+
const proxyUrls = text.split('\r\n');
652+
const rand = Math.floor(Math.random() * Math.floor(proxyUrls.length));
653+
const proxyUrl = 'http://' + proxyUrls[rand];
654+
options = { agent: makeProxyAgent(proxyUrl), fetchAgent: makeProxyAgentUndici(proxyUrl) };
655+
} catch {
656+
this.localProxy.enabled = false;
657+
}
658+
} else {
659+
options = {
660+
agent: makeProxyAgent({
661+
host: this.localProxy.host,
662+
port: this.localProxy.port,
663+
protocol: this.localProxy.protocol,
664+
username: this.localProxy.username,
665+
password: this.localProxy.password,
666+
}),
667+
fetchAgent: makeProxyAgentUndici({
668+
host: this.localProxy.host,
669+
port: this.localProxy.port,
670+
protocol: this.localProxy.protocol,
671+
username: this.localProxy.username,
672+
password: this.localProxy.password,
673+
}),
674+
};
617675
}
618-
} else {
619-
options = {
620-
agent: makeProxyAgent({
621-
host: this.localProxy.host,
622-
port: this.localProxy.port,
623-
protocol: this.localProxy.protocol,
624-
username: this.localProxy.username,
625-
password: this.localProxy.password,
626-
}),
627-
fetchAgent: makeProxyAgentUndici({
628-
host: this.localProxy.host,
629-
port: this.localProxy.port,
630-
protocol: this.localProxy.protocol,
631-
username: this.localProxy.username,
632-
password: this.localProxy.password,
633-
}),
634-
};
635676
}
636-
}
637677

638-
const socketConfig: UserFacingSocketConfig = {
639-
...options,
640-
version,
641-
logger: P({ level: this.logBaileys }),
642-
printQRInTerminal: false,
643-
auth: {
644-
creds: this.instance.authState.state.creds,
645-
keys: makeCacheableSignalKeyStore(this.instance.authState.state.keys, P({ level: 'error' }) as any),
646-
},
647-
msgRetryCounterCache: this.msgRetryCounterCache,
648-
generateHighQualityLinkPreview: true,
649-
getMessage: async (key) => (await this.getMessage(key)) as Promise<proto.IMessage>,
650-
...browserOptions,
651-
markOnlineOnConnect: this.localSettings.alwaysOnline,
652-
retryRequestDelayMs: 350,
653-
maxMsgRetryCount: 4,
654-
fireInitQueries: true,
655-
connectTimeoutMs: 30_000,
656-
keepAliveIntervalMs: 30_000,
657-
qrTimeout: 45_000,
658-
emitOwnEvents: false,
659-
shouldIgnoreJid: (jid) => {
660-
if (this.localSettings.syncFullHistory && isJidGroup(jid)) {
661-
return false;
662-
}
678+
const socketConfig: UserFacingSocketConfig = {
679+
...options,
680+
version,
681+
logger: P({ level: this.logBaileys }),
682+
printQRInTerminal: false,
683+
auth: {
684+
creds: this.instance.authState.state.creds,
685+
keys: makeCacheableSignalKeyStore(this.instance.authState.state.keys, P({ level: 'error' }) as any),
686+
},
687+
msgRetryCounterCache: this.msgRetryCounterCache,
688+
generateHighQualityLinkPreview: true,
689+
getMessage: async (key) => (await this.getMessage(key)) as Promise<proto.IMessage>,
690+
...browserOptions,
691+
markOnlineOnConnect: this.localSettings.alwaysOnline,
692+
retryRequestDelayMs: 350,
693+
maxMsgRetryCount: 4,
694+
fireInitQueries: true,
695+
connectTimeoutMs: 30_000,
696+
keepAliveIntervalMs: 30_000,
697+
qrTimeout: 45_000,
698+
emitOwnEvents: false,
699+
shouldIgnoreJid: (jid) => {
700+
if (this.localSettings.syncFullHistory && isJidGroup(jid)) {
701+
return false;
702+
}
663703

664-
const isGroupJid = this.localSettings.groupsIgnore && isJidGroup(jid);
665-
const isBroadcast = !this.localSettings.readStatus && isJidBroadcast(jid);
666-
const isNewsletter = isJidNewsletter(jid);
704+
const isGroupJid = this.localSettings.groupsIgnore && isJidGroup(jid);
705+
const isBroadcast = !this.localSettings.readStatus && isJidBroadcast(jid);
706+
const isNewsletter = isJidNewsletter(jid);
667707

668-
return isGroupJid || isBroadcast || isNewsletter;
669-
},
670-
syncFullHistory: this.localSettings.syncFullHistory,
671-
shouldSyncHistoryMessage: (msg: proto.Message.IHistorySyncNotification) => {
672-
return this.historySyncNotification(msg);
673-
},
674-
cachedGroupMetadata: this.getGroupMetadataCache,
675-
userDevicesCache: this.userDevicesCache,
676-
transactionOpts: { maxCommitRetries: 10, delayBetweenTriesMs: 3000 },
677-
patchMessageBeforeSending(message) {
678-
if (
679-
message.deviceSentMessage?.message?.listMessage?.listType === proto.Message.ListMessage.ListType.PRODUCT_LIST
680-
) {
681-
message = JSON.parse(JSON.stringify(message));
708+
return isGroupJid || isBroadcast || isNewsletter;
709+
},
710+
syncFullHistory: this.localSettings.syncFullHistory,
711+
shouldSyncHistoryMessage: (msg: proto.Message.IHistorySyncNotification) => {
712+
return this.historySyncNotification(msg);
713+
},
714+
cachedGroupMetadata: this.getGroupMetadataCache,
715+
userDevicesCache: this.userDevicesCache,
716+
transactionOpts: { maxCommitRetries: 10, delayBetweenTriesMs: 3000 },
717+
patchMessageBeforeSending(message) {
718+
if (
719+
message.deviceSentMessage?.message?.listMessage?.listType ===
720+
proto.Message.ListMessage.ListType.PRODUCT_LIST
721+
) {
722+
message = JSON.parse(JSON.stringify(message));
682723

683-
message.deviceSentMessage.message.listMessage.listType = proto.Message.ListMessage.ListType.SINGLE_SELECT;
684-
}
724+
message.deviceSentMessage.message.listMessage.listType = proto.Message.ListMessage.ListType.SINGLE_SELECT;
725+
}
685726

686-
if (message.listMessage?.listType == proto.Message.ListMessage.ListType.PRODUCT_LIST) {
687-
message = JSON.parse(JSON.stringify(message));
727+
if (message.listMessage?.listType == proto.Message.ListMessage.ListType.PRODUCT_LIST) {
728+
message = JSON.parse(JSON.stringify(message));
688729

689-
message.listMessage.listType = proto.Message.ListMessage.ListType.SINGLE_SELECT;
690-
}
730+
message.listMessage.listType = proto.Message.ListMessage.ListType.SINGLE_SELECT;
731+
}
691732

692-
return message;
693-
},
694-
};
733+
return message;
734+
},
735+
};
695736

696-
this.endSession = false;
737+
this.endSession = false;
697738

698-
this.client = makeWASocket(socketConfig);
739+
this.client = makeWASocket(socketConfig);
699740

700-
if (this.localSettings.wavoipToken && this.localSettings.wavoipToken.length > 0) {
701-
useVoiceCallsBaileys(this.localSettings.wavoipToken, this.client, this.connectionStatus.state as any, true);
702-
}
741+
if (this.localSettings.wavoipToken && this.localSettings.wavoipToken.length > 0) {
742+
useVoiceCallsBaileys(this.localSettings.wavoipToken, this.client, this.connectionStatus.state as any, true);
743+
}
703744

704-
this.eventHandler();
745+
this.eventHandler();
705746

706-
this.client.ws.on('CB:call', (packet) => {
707-
console.log('CB:call', packet);
708-
const payload = { event: 'CB:call', packet: packet };
709-
this.sendDataWebhook(Events.CALL, payload, true, ['websocket']);
710-
});
747+
this.client.ws.on('CB:call', (packet) => {
748+
console.log('CB:call', packet);
749+
const payload = { event: 'CB:call', packet: packet };
750+
this.sendDataWebhook(Events.CALL, payload, true, ['websocket']);
751+
});
711752

712-
this.client.ws.on('CB:ack,class:call', (packet) => {
713-
console.log('CB:ack,class:call', packet);
714-
const payload = { event: 'CB:ack,class:call', packet: packet };
715-
this.sendDataWebhook(Events.CALL, payload, true, ['websocket']);
716-
});
753+
this.client.ws.on('CB:ack,class:call', (packet) => {
754+
console.log('CB:ack,class:call', packet);
755+
const payload = { event: 'CB:ack,class:call', packet: packet };
756+
this.sendDataWebhook(Events.CALL, payload, true, ['websocket']);
757+
});
717758

718-
this.phoneNumber = number;
759+
this.phoneNumber = number;
719760

720-
return this.client;
761+
return this.client;
762+
} finally {
763+
this.isConnecting = false;
764+
}
721765
}
722766

723767
public async connectToWhatsapp(number?: string): Promise<WASocket> {

0 commit comments

Comments
 (0)