@@ -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