Skip to content

Commit c043dd0

Browse files
committed
fix: notifica falhas silenciosas de QR no Chatwoot e reconcilia estado zumbi
Dois problemas distintos, mas relacionados: 1) QR solicitado pelo Chatwoot as vezes simplesmente nao aparecia na conversa - mesmo com a instancia genuinamente desconectada, nao so em cenario de estado "zumbi". Causa: falhas engolidas em silencio. receiveWebhook (comando 'init') e o handler de qrcode.updated estavam em try/catch que so logava no servidor, sem nenhum feedback ao operador no Chatwoot. Havia tambem um bug de optional chaining (body?.qrcode.base64) que podia lancar TypeError silencioso se body.qrcode viesse undefined. 2) Instancia aparecia "conectada" na Evolution mesmo com o WhatsApp real caido. Causa: o status de conexao e 100% orientado a evento (connection.update); se esse evento nunca dispara (ex. socket morrendo sem RST/FIN, comum sob CPU steal alto), o valor cacheado em memoria/banco fica preso em 'open' para sempre. Nao havia nenhuma sonda ativa (confirmado: zero ocorrencias de setInterval em todo o src/). Mudancas: - receiveWebhook: erro ao conectar agora notifica a conversa (cw.inbox.qrError); catch geral tambem notifica erro generico (cw.inbox.requestError) alem de logar. - Corrigido o optional chaining (body?.qrcode?.base64) com guarda explicita e notificacao de erro. - Handler de qrcode.updated: catch notifica cw.inbox.qrError, mas so quando o QR ainda nao tinha sido postado com sucesso (flag qrPosted) - evita notificar erro depois que a imagem do QR ja chegou na conversa. - monitor.service.ts: health check periodico (30s) que confirma via client.ws.isOpen se instancias marcadas 'open' realmente tem socket ativo; se nao, reconcilia o estado para 'close' (banco + webhook CONNECTION_UPDATE), reaproveitando os mesmos campos ja usados no fechamento real da conexao. - instance.controller.ts: connectionState reconcilia sob demanda antes de responder, alem do health check periodico. - Novas chaves de traducao cw.inbox.qrError/cw.inbox.requestError em pt-BR/en/es.
1 parent fa09d37 commit c043dd0

6 files changed

Lines changed: 142 additions & 3 deletions

File tree

src/api/controllers/instance.controller.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,15 @@ export class InstanceController {
391391
}
392392

393393
public async connectionState({ instanceName }: InstanceDto) {
394+
// Cross-check the cached state against the real WebSocket before answering, so a
395+
// "zombie open" instance (state cached as 'open' after the socket already dropped)
396+
// is reconciled here too, not only on the next periodic health check.
397+
try {
398+
await this.waMonitor.reconcileInstanceConnection(instanceName);
399+
} catch (error) {
400+
this.logger.error(error);
401+
}
402+
394403
return {
395404
instance: {
396405
instanceName: instanceName,

src/api/integrations/chatbot/chatwoot/services/chatwoot.service.ts

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1381,7 +1381,12 @@ export class ChatwootService {
13811381

13821382
if (state !== 'open') {
13831383
const number = command.split(':')[1];
1384-
await waInstance.connectToWhatsapp(number);
1384+
try {
1385+
await waInstance.connectToWhatsapp(number);
1386+
} catch (connectError) {
1387+
this.logger.error(connectError);
1388+
await this.createBotMessage(instance, i18next.t('cw.inbox.qrError'), 'incoming');
1389+
}
13851390
} else {
13861391
await this.createBotMessage(
13871392
instance,
@@ -1601,6 +1606,13 @@ export class ChatwootService {
16011606
return { message: 'bot' };
16021607
} catch (error) {
16031608
this.logger.error(error);
1609+
this.logger.error(error?.stack ?? error);
1610+
1611+
try {
1612+
await this.createBotMessage(instance, i18next.t('cw.inbox.requestError'), 'incoming');
1613+
} catch (notifyError) {
1614+
this.logger.error(notifyError);
1615+
}
16041616

16051617
return { message: 'bot' };
16061618
}
@@ -1948,6 +1960,10 @@ export class ChatwootService {
19481960
}
19491961

19501962
public async eventWhatsapp(event: string, instance: InstanceDto, body: any) {
1963+
// Tracks whether createBotQr already succeeded, so a later failure in this same
1964+
// handler (e.g. posting the accompanying text message) doesn't also send a
1965+
// confusing "QR failed" notification into a conversation that just received the QR.
1966+
let qrPosted = false;
19511967
try {
19521968
const waInstance = this.waMonitor.waInstances[instance.instanceName];
19531969

@@ -2493,7 +2509,17 @@ export class ChatwootService {
24932509
const erroQRcode = `🚨 ${i18next.t('qrlimitreached')}`;
24942510
return await this.createBotMessage(instance, erroQRcode, 'incoming');
24952511
} else {
2496-
const fileData = Buffer.from(body?.qrcode.base64.replace('data:image/png;base64,', ''), 'base64');
2512+
const qrBase64 = body?.qrcode?.base64;
2513+
2514+
if (!qrBase64) {
2515+
this.logger.error(
2516+
`qrcode.updated event received without a valid qrcode.base64 payload for instance ${instance?.instanceName}`,
2517+
);
2518+
await this.createBotMessage(instance, i18next.t('cw.inbox.qrError'), 'incoming');
2519+
return;
2520+
}
2521+
2522+
const fileData = Buffer.from(qrBase64.replace('data:image/png;base64,', ''), 'base64');
24972523

24982524
const fileStream = new Readable();
24992525
fileStream._read = () => {};
@@ -2507,6 +2533,7 @@ export class ChatwootService {
25072533
fileStream,
25082534
`${instance.instanceName}.png`,
25092535
);
2536+
qrPosted = true;
25102537

25112538
let msgQrCode = `⚡️${i18next.t('qrgeneratedsuccesfully')}\n\n${i18next.t('scanqr')}`;
25122539

@@ -2524,6 +2551,15 @@ export class ChatwootService {
25242551
}
25252552
} catch (error) {
25262553
this.logger.error(error);
2554+
this.logger.error(error?.stack ?? error);
2555+
2556+
if (event === 'qrcode.updated' && !qrPosted) {
2557+
try {
2558+
await this.createBotMessage(instance, i18next.t('cw.inbox.qrError'), 'incoming');
2559+
} catch (notifyError) {
2560+
this.logger.error(notifyError);
2561+
}
2562+
}
25272563
}
25282564
}
25292565

src/api/services/monitor.service.ts

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { InstanceDto } from '@api/dto/instance.dto';
22
import { ProviderFiles } from '@api/provider/sessions';
33
import { PrismaRepository } from '@api/repository/repository.service';
44
import { channelController } from '@api/server.module';
5-
import { Events, Integration } from '@api/types/wa.types';
5+
import { Events, Integration, wa } from '@api/types/wa.types';
66
import { CacheConf, Chatwoot, ConfigService, Database, DelInstance, ProviderSession } from '@config/env.config';
77
import { Logger } from '@config/logger.config';
88
import { INSTANCE_DIR, STORE_DIR } from '@config/path.config';
@@ -26,6 +26,7 @@ export class WAMonitoringService {
2626
) {
2727
this.removeInstance();
2828
this.noConnection();
29+
this.startConnectionHealthCheck();
2930

3031
Object.assign(this.db, configService.get<Database>('DATABASE'));
3132
Object.assign(this.redis, configService.get<CacheConf>('CACHE'));
@@ -42,6 +43,12 @@ export class WAMonitoringService {
4243

4344
private readonly providerSession: ProviderSession;
4445

46+
// Health check: how often (ms) we probe instances cached as 'open' to confirm the
47+
// underlying WebSocket is really still connected (mitigates the "zombie open" state,
48+
// since connectionStatus is otherwise 100% event-driven from connection.update).
49+
private readonly HEALTH_CHECK_INTERVAL_MS = 30_000;
50+
private healthCheckInterval: NodeJS.Timeout;
51+
4552
public delInstanceTime(instance: string) {
4653
const time = this.configService.get<DelInstance>('DEL_INSTANCE');
4754
if (typeof time === 'number' && time > 0) {
@@ -83,6 +90,87 @@ export class WAMonitoringService {
8390
}
8491
}
8592

93+
/**
94+
* Starts the periodic reconciliation of cached connection state ("zombie open" mitigation).
95+
* connectionStatus.state is otherwise only ever written from inside the Baileys
96+
* 'connection.update' handler, so if that event is ever missed (process hiccup, socket
97+
* torn down outside Baileys' own reconnection flow, etc.) an instance can stay marked
98+
* 'open' in memory/DB forever even though the WebSocket is gone.
99+
*/
100+
private startConnectionHealthCheck() {
101+
this.healthCheckInterval = setInterval(() => {
102+
this.checkInstancesConnection();
103+
}, this.HEALTH_CHECK_INTERVAL_MS);
104+
}
105+
106+
private async checkInstancesConnection() {
107+
for (const instanceName of Object.keys(this.waInstances)) {
108+
try {
109+
await this.reconcileInstanceConnection(instanceName);
110+
} catch (error) {
111+
// A failure checking one instance must never abort the loop or affect the others.
112+
this.logger.error(`Health check failed for instance "${instanceName}": ${error}`);
113+
}
114+
}
115+
}
116+
117+
/**
118+
* Confirms that an instance cached as 'open' still has a real, open WebSocket
119+
* (client.ws.isOpen, per Baileys' AbstractSocketClient). If it does not, forces
120+
* stateConnection to 'close', persists it via the same Prisma fields used by
121+
* BaileysStartupService.connectionUpdate() on a genuine close, and re-emits
122+
* CONNECTION_UPDATE through the instance's own sendDataWebhook (no duplicated logic).
123+
*
124+
* Used both by the periodic health check above and on-demand by
125+
* InstanceController.connectionState() before answering a status request.
126+
*/
127+
public async reconcileInstanceConnection(instanceName: string): Promise<wa.StateConnection | undefined> {
128+
const waInstance = this.waInstances[instanceName];
129+
130+
if (!waInstance || waInstance.connectionStatus?.state !== 'open') {
131+
return waInstance?.connectionStatus;
132+
}
133+
134+
const client = waInstance.client;
135+
136+
// Only instances with an initialized Baileys client expose ws.isOpen; instances
137+
// without one (still connecting, non-Baileys channel, etc.) are left untouched.
138+
if (!client?.ws || client.ws.isOpen) {
139+
return waInstance.connectionStatus;
140+
}
141+
142+
this.logger.warn(
143+
`Instance "${instanceName}" is cached as 'open' but its WebSocket is not open (isOpen=false). Reconciling status to 'close'.`,
144+
);
145+
146+
waInstance.stateConnection = { state: 'close', statusReason: 428 };
147+
148+
try {
149+
await this.prismaRepository.instance.update({
150+
where: { id: waInstance.instanceId },
151+
data: {
152+
connectionStatus: 'close',
153+
disconnectionAt: new Date(),
154+
disconnectionReasonCode: 428,
155+
disconnectionObject: JSON.stringify({ reason: 'health-check: websocket not open' }),
156+
},
157+
});
158+
} catch (error) {
159+
this.logger.error(`Failed to persist reconciled connection state for "${instanceName}": ${error}`);
160+
}
161+
162+
try {
163+
await waInstance.sendDataWebhook?.(Events.CONNECTION_UPDATE, {
164+
instance: instanceName,
165+
...waInstance.stateConnection,
166+
});
167+
} catch (error) {
168+
this.logger.error(`Failed to emit CONNECTION_UPDATE for "${instanceName}": ${error}`);
169+
}
170+
171+
return waInstance.connectionStatus;
172+
}
173+
86174
public async instanceInfo(instanceNames?: string[]): Promise<any> {
87175
if (instanceNames && instanceNames.length > 0) {
88176
const inexistentInstances = instanceNames ? instanceNames.filter((instance) => !this.waInstances[instance]) : [];

src/utils/translations/en.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
"qrgeneratedsuccesfully": "QRCode successfully generated!",
33
"scanqr": "Scan this QR code within the next 40 seconds.",
44
"qrlimitreached": "QRCode generation limit reached, to generate a new QRCode, send the 'init' message again.",
5+
"cw.inbox.qrError": "🚨 Failed to generate the QR Code. Please try again by sending 'init' in this conversation.",
6+
"cw.inbox.requestError": "⚠️ An error occurred while processing your request. If you were trying to connect via QR Code, please try again by sending 'init'.",
57
"cw.inbox.connected": "🚀 Connection successfully established!",
68
"cw.inbox.disconnect": "🚨 Disconnecting WhatsApp from inbox *{{inboxName}}*.",
79
"cw.inbox.alreadyConnected": "🚨 {{inboxName}} instance is connected.",

src/utils/translations/es.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
"qrgeneratedsuccesfully": "Código QR generado exitosamente!",
33
"scanqr": "Escanea este código QR en los próximos 40 segundos.",
44
"qrlimitreached": "🚨 Se alcanzó el límite de generación de QRCode. Para generar un nuevo QRCode, envíe el mensaje 'init' nuevamente.",
5+
"cw.inbox.qrError": "🚨 No se pudo generar el código QR. Por favor, intente nuevamente enviando 'init' en esta conversación.",
6+
"cw.inbox.requestError": "⚠️ Ocurrió un error al procesar su solicitud. Si estaba intentando conectar mediante código QR, intente nuevamente enviando 'init'.",
57
"cw.inbox.connected": "🚀 ¡Conexión establecida exitosamente!",
68
"cw.inbox.disconnect": "🚨 Instancia *{{inboxName}}* desconectado de Whatsapp.",
79
"cw.inbox.alreadyConnected": "🚨 La instancia {{inboxName}} está conectada.",

src/utils/translations/pt-BR.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
"qrgeneratedsuccesfully": "QRCode gerado com sucesso!",
33
"scanqr": "Escaneie o QRCode com o WhatsApp nos próximos 40 segundos.",
44
"qrlimitreached": "Limite de geração de QRCode atingido! Para gerar um novo QRCode, envie o texto 'init' nesta conversa.",
5+
"cw.inbox.qrError": "🚨 Falha ao gerar o QR Code. Por favor, tente novamente enviando 'init' nesta conversa.",
6+
"cw.inbox.requestError": "⚠️ Ocorreu um erro ao processar sua solicitação. Se você estava tentando conectar via QR Code, tente novamente enviando 'init'.",
57
"cw.inbox.connected": "🚀 Conectado com sucesso!",
68
"cw.inbox.disconnect": "🚨 Instância *{{inboxName}}* desconectada do WhatsApp.",
79
"cw.inbox.alreadyConnected": "🚨 Instância *{{inboxName}}* já está conectada.",

0 commit comments

Comments
 (0)