feat(chatwoot): comprehensive improvements to message handling, editing, deletion and i18n

- Fix bidirectional message deletion between Chatwoot and WhatsApp
- Support deletion of multiple attachments sent together
- Implement proper message editing with 'Edited Message:' prefix format
- Enable deletion of edited messages by updating chatwootMessageId
- Skip cache for deleted messages (messageStubType === 1) to prevent duplicates
- Fix i18n translation path detection for production environment
- Add automatic dev/prod path resolution for translation files
- Improve error handling and logging for message operations

Technical improvements:
- Changed Chatwoot deletion query from findFirst to findMany for multiple attachments
- Fixed instanceId override issue in message deletion payload
- Added retry logic with Prisma MessageUpdate validation
- Implemented cache bypass for revoked messages to ensure proper processing
- Enhanced i18n to detect dist/ folder in production vs src/ in development

Resolves issues with:
- Message deletion not working from Chatwoot to WhatsApp
- Multiple attachments causing incomplete deletion
- Edited messages showing raw i18n keys instead of translated text
- Cache collision preventing deletion of edited messages
- Production environment not loading translation files correctly

Note: Tested and validated with Chatwoot v4.1 in production environment
This commit is contained in:
Anderson Silva 2025-10-03 14:47:24 -03:00
parent 78c7b96f0f
commit 6e1d027750
3 changed files with 407 additions and 97 deletions

View File

@ -1065,6 +1065,11 @@ export class BaileysStartupService extends ChannelStartupService {
settings: any,
) => {
try {
// Garantir que localChatwoot está carregado antes de processar mensagens
if (this.configService.get<Chatwoot>('CHATWOOT').ENABLED && !this.localChatwoot?.enabled) {
await this.loadChatwoot();
}
for (const received of messages) {
if (received.key.remoteJid?.includes('@lid') && (received.key as ExtendedMessageKey).senderPn) {
(received.key as ExtendedMessageKey).previousRemoteJid = received.key.remoteJid;
@ -1445,12 +1450,17 @@ export class BaileysStartupService extends ChannelStartupService {
const cached = await this.baileysCache.get(updateKey);
if (cached) {
// Não ignorar mensagens deletadas (messageStubType === 1) mesmo que estejam em cache
const isDeletedMessage = update.messageStubType === 1;
if (cached && !isDeletedMessage) {
this.logger.info(`Message duplicated ignored [avoid deadlock]: ${updateKey}`);
continue;
}
await this.baileysCache.set(updateKey, true, 30 * 60);
if (!isDeletedMessage) {
await this.baileysCache.set(updateKey, true, 30 * 60);
}
if (status[update.status] === 'READ' && key.fromMe) {
if (this.configService.get<Chatwoot>('CHATWOOT').ENABLED && this.localChatwoot?.enabled) {
@ -1550,8 +1560,22 @@ export class BaileysStartupService extends ChannelStartupService {
this.sendDataWebhook(Events.MESSAGES_UPDATE, message);
if (this.configService.get<Database>('DATABASE').SAVE_DATA.MESSAGE_UPDATE)
await this.prismaRepository.messageUpdate.create({ data: message });
if (this.configService.get<Database>('DATABASE').SAVE_DATA.MESSAGE_UPDATE) {
// Verificar se a mensagem ainda existe antes de criar o update
const messageExists = await this.prismaRepository.message.findFirst({
where: {
instanceId: message.instanceId,
key: {
path: ['id'],
equals: message.keyId,
},
},
});
if (messageExists) {
await this.prismaRepository.messageUpdate.create({ data: message });
}
}
const existingChat = await this.prismaRepository.chat.findFirst({
where: { instanceId: this.instanceId, remoteJid: message.remoteJid },

View File

@ -979,7 +979,7 @@ export class ChatwootService {
private async sendData(
conversationId: number,
fileStream: Readable,
fileData: Buffer | Readable,
fileName: string,
messageType: 'incoming' | 'outgoing' | undefined,
content?: string,
@ -1005,7 +1005,7 @@ export class ChatwootService {
data.append('message_type', messageType);
data.append('attachments[]', fileStream, { filename: fileName });
data.append('attachments[]', fileData, { filename: fileName });
const sourceReplyId = quotedMsg?.chatwootMessageId || null;
@ -1123,20 +1123,135 @@ 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: 60000, // 60 segundos de timeout para arquivos grandes
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 se receber 404 (arquivo ainda não disponível)
this.logger.verbose('Downloading from S3/MinIO...');
let s3Response;
let retryCount = 0;
const maxRetries = 3;
const retryDelay = 2000; // 2 segundos entre tentativas
while (retryCount <= maxRetries) {
s3Response = await axios.get(redirectUrl, {
responseType: 'arraybuffer',
timeout: 60000, // 60 segundos para arquivos grandes
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 e tentar novamente
if (retryCount < maxRetries) {
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 ${retryDelay}ms...`,
);
this.logger.verbose(`MinIO Response: ${errorBody}`);
await new Promise((resolve) => setTimeout(resolve, retryDelay));
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('Error downloading media from: ' + media);
this.logger.error(downloadError);
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 +1269,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 +1286,12 @@ export class ChatwootService {
return messageSent;
}
// Para outros tipos, converter para base64 puro (sem prefixo data URI)
const base64Media = mediaBuffer.toString('base64');
const documentExtensions = ['.gif', '.svg', '.tiff', '.tif'];
if (type === 'image' && parsedMedia && documentExtensions.includes(parsedMedia?.ext)) {
const parsedExt = path.parse(fileName)?.ext;
if (type === 'image' && parsedExt && documentExtensions.includes(parsedExt)) {
type = 'document';
}
@ -1178,7 +1299,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 +1315,7 @@ export class ChatwootService {
return messageSent;
} catch (error) {
this.logger.error(error);
throw error; // Re-throw para que o erro seja tratado pelo caller
}
}
@ -1254,6 +1376,61 @@ 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) {
this.logger.verbose(`Processing message deletion from Chatwoot - 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.verbose(`Found ${messages.length} message(s) to delete from Chatwoot message ${body.id}`);
// Deletar cada mensagem no WhatsApp
for (const message of messages) {
const key = message.key as ExtendedMessageKey;
this.logger.verbose(`Deleting WhatsApp message - keyId: ${key?.id}`);
try {
await waInstance?.client.sendMessage(key.remoteJid, { delete: key });
this.logger.verbose(`Message ${key.id} deleted in WhatsApp successfully`);
} catch (error) {
this.logger.error(`Error deleting message ${key.id} in WhatsApp: ${error}`);
}
}
// Remover todas as mensagens do banco de dados
await this.prismaRepository.message.deleteMany({
where: {
instanceId: instance.instanceId,
chatwootMessageId: body.id,
},
});
this.logger.verbose(`${messages.length} message(s) removed from database`);
} else {
// Mensagem não encontrada - pode ser uma mensagem antiga que foi substituída por edição
// Nesse caso, ignoramos silenciosamente pois o ID já foi atualizado no banco
this.logger.verbose(
`Message not found for chatwootMessageId: ${body.id} - may have been replaced by an edited message`,
);
}
return { message: 'deleted' };
}
if (
!body?.conversation ||
body.private ||
@ -1276,29 +1453,6 @@ export class ChatwootService {
const senderName = body?.conversation?.messages[0]?.sender?.available_name || body?.sender?.name;
const waInstance = this.waMonitor.waInstances[instance.instanceName];
if (body.event === 'message_updated' && body.content_attributes?.deleted) {
const message = await this.prismaRepository.message.findFirst({
where: {
chatwootMessageId: body.id,
instanceId: instance.instanceId,
},
});
if (message) {
const key = message.key as ExtendedMessageKey;
await waInstance?.client.sendMessage(key.remoteJid, { delete: key });
await this.prismaRepository.message.deleteMany({
where: {
instanceId: instance.instanceId,
chatwootMessageId: body.id,
},
});
}
return { message: 'bot' };
}
const cwBotContact = this.configService.get<Chatwoot>('CHATWOOT').BOT_CONTACT;
if (chatId === '123456' && body.message_type === 'outgoing') {
@ -1394,40 +1548,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 +1622,7 @@ export class ChatwootService {
}
await this.updateChatwootMessageId(
{
...messageSent,
instanceId: instance.instanceId,
},
messageSent, // Já tem instanceId
{
messageId: body.id,
inboxId: body.inbox?.id,
@ -1544,11 +1713,55 @@ export class ChatwootService {
const key = message.key as ExtendedMessageKey;
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}`,
);
// Aguarda um pequeno delay para garantir que a mensagem foi criada no banco
await new Promise((resolve) => setTimeout(resolve, 100));
// Verifica se a mensagem existe antes de atualizar
let retries = 0;
const maxRetries = 5;
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++;
this.logger.verbose(`Message not found, retry ${retries}/${maxRetries}`);
await new Promise((resolve) => setTimeout(resolve, 200));
}
}
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 +1769,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);
}
@ -1996,11 +2211,6 @@ export class ChatwootService {
const fileData = Buffer.from(downloadBase64.base64, 'base64');
const fileStream = new Readable();
fileStream._read = () => {};
fileStream.push(fileData);
fileStream.push(null);
if (body.key.remoteJid.includes('@g.us')) {
const participantName = body.pushName;
const rawPhoneNumber = body.key.participant.split('@')[0];
@ -2024,7 +2234,7 @@ export class ChatwootService {
const send = await this.sendData(
getConversation,
fileStream,
fileData,
nameFile,
messageType,
content,
@ -2043,7 +2253,7 @@ export class ChatwootService {
} else {
const send = await this.sendData(
getConversation,
fileStream,
fileData,
nameFile,
messageType,
bodyMessage,
@ -2109,11 +2319,6 @@ export class ChatwootService {
});
const processedBuffer = await img.getBuffer(JimpMime.png);
const fileStream = new Readable();
fileStream._read = () => {}; // _read is required but you can noop it
fileStream.push(processedBuffer);
fileStream.push(null);
const truncStr = (str: string, len: number) => {
if (!str) return '';
@ -2125,7 +2330,7 @@ export class ChatwootService {
const send = await this.sendData(
getConversation,
fileStream,
processedBuffer,
nameFile,
messageType,
`${bodyMessage}\n\n\n**${title}**\n${description}\n${adsMessage.sourceUrl}`,
@ -2235,9 +2440,53 @@ 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;
// Se não houver conteúdo editado, verificar se é uma deleção
if (!editedMessageContent || editedMessageContent.trim() === '') {
// Verificar se é uma mensagem revogada (messageStubType: 1)
const messageStubType = body?.update?.messageStubType || body?.messageStubType;
this.logger.verbose(
`No edited content found - messageStubType: ${messageStubType}, body.update: ${JSON.stringify(body?.update)}`,
);
if (messageStubType === 1) {
// É uma mensagem deletada - processar exclusão no Chatwoot
this.logger.verbose('Message revoked detected, processing deletion in Chatwoot');
const message = await this.getMessageByKeyId(instance, body?.key?.id);
if (message?.chatwootMessageId && message?.chatwootConversationId) {
try {
await client.messages.delete({
accountId: this.provider.accountId,
conversationId: message.chatwootConversationId,
messageId: message.chatwootMessageId,
});
this.logger.verbose(`Deleted revoked message ${message.chatwootMessageId} in Chatwoot`);
// Remover do banco de dados
await this.prismaRepository.message.deleteMany({
where: {
key: {
path: ['id'],
equals: body.key.id,
},
instanceId: instance.instanceId,
},
});
this.logger.verbose(`Removed revoked message from database`);
} catch (error) {
this.logger.error(`Error deleting revoked message: ${error}`);
}
}
} else {
this.logger.verbose('Message deleted, skipping edit notification');
}
return;
}
const message = await this.getMessageByKeyId(instance, body?.key?.id);
if (!message) {
@ -2246,10 +2495,24 @@ export class ChatwootService {
}
const key = message.key as ExtendedMessageKey;
const messageType = key?.fromMe ? 'outgoing' : 'incoming';
if (message && message.chatwootConversationId) {
if (message && message.chatwootConversationId && message.chatwootMessageId) {
// Deletar a mensagem original no Chatwoot
try {
await client.messages.delete({
accountId: this.provider.accountId,
conversationId: message.chatwootConversationId,
messageId: message.chatwootMessageId,
});
this.logger.verbose(`Deleted original message ${message.chatwootMessageId} for edit`);
} catch (error) {
this.logger.error(`Error deleting original message for edit: ${error}`);
}
// Criar nova mensagem com formato: "Mensagem editada:\n\nteste1"
const editedText = `${i18next.t('cw.message.edited')}:\n\n${editedMessageContent}`;
const send = await this.createMessage(
instance,
message.chatwootConversationId,
@ -2263,10 +2526,31 @@ export class ChatwootService {
'WAID:' + body.key.id,
null,
);
if (!send) {
this.logger.warn('edited message not sent');
return;
}
this.logger.verbose(`Created edited message in Chatwoot with ID: ${send.id}`);
// Atualizar o chatwootMessageId no banco para apontar para a nova mensagem
// Isso permite que a exclusão funcione após a edição
try {
await this.prismaRepository.message.update({
where: {
id: message.id,
},
data: {
chatwootMessageId: send.id,
},
});
this.logger.verbose(
`Updated chatwootMessageId from ${message.chatwootMessageId} to ${send.id} for message ${body.key.id}`,
);
} catch (error) {
this.logger.error(`Error updating chatwootMessageId after edit: ${error}`);
}
}
return;
}

View File

@ -3,10 +3,12 @@ import fs from 'fs';
import i18next from 'i18next';
import path from 'path';
const __dirname = path.resolve(process.cwd(), 'src', 'utils');
// Detect if running from dist/ (production) or src/ (development)
const isProduction = fs.existsSync(path.join(process.cwd(), 'dist'));
const baseDir = isProduction ? 'dist' : 'src/utils';
const translationsPath = path.join(process.cwd(), baseDir, 'translations');
const languages = ['en', 'pt-BR', 'es'];
const translationsPath = path.join(__dirname, 'translations');
const configService: ConfigService = new ConfigService();
const resources: any = {};