Merge branch 'develop' into feat/add-async-lock

This commit is contained in:
Davidson Gomes
2025-07-16 19:22:44 -03:00
committed by GitHub
32 changed files with 3397 additions and 2082 deletions

View File

@@ -455,39 +455,46 @@ export class EvolutionStartupService extends ChannelStartupService {
if (base64 || file || audioFile) {
if (this.configService.get<S3>('S3').ENABLE) {
try {
const fileBuffer = audioFile?.buffer || file?.buffer;
const buffer = base64 ? Buffer.from(base64, 'base64') : fileBuffer;
// Verificação adicional para garantir que há conteúdo de mídia real
const hasRealMedia = this.hasValidMediaContent(messageRaw);
let mediaType: string;
let mimetype = audioFile?.mimetype || file.mimetype;
if (!hasRealMedia) {
this.logger.warn('Message detected as media but contains no valid media content');
} else {
const fileBuffer = audioFile?.buffer || file?.buffer;
const buffer = base64 ? Buffer.from(base64, 'base64') : fileBuffer;
if (messageRaw.messageType === 'documentMessage') {
mediaType = 'document';
mimetype = !mimetype ? 'application/pdf' : mimetype;
} else if (messageRaw.messageType === 'imageMessage') {
mediaType = 'image';
mimetype = !mimetype ? 'image/png' : mimetype;
} else if (messageRaw.messageType === 'audioMessage') {
mediaType = 'audio';
mimetype = !mimetype ? 'audio/mp4' : mimetype;
} else if (messageRaw.messageType === 'videoMessage') {
mediaType = 'video';
mimetype = !mimetype ? 'video/mp4' : mimetype;
let mediaType: string;
let mimetype = audioFile?.mimetype || file.mimetype;
if (messageRaw.messageType === 'documentMessage') {
mediaType = 'document';
mimetype = !mimetype ? 'application/pdf' : mimetype;
} else if (messageRaw.messageType === 'imageMessage') {
mediaType = 'image';
mimetype = !mimetype ? 'image/png' : mimetype;
} else if (messageRaw.messageType === 'audioMessage') {
mediaType = 'audio';
mimetype = !mimetype ? 'audio/mp4' : mimetype;
} else if (messageRaw.messageType === 'videoMessage') {
mediaType = 'video';
mimetype = !mimetype ? 'video/mp4' : mimetype;
}
const fileName = `${messageRaw.key.id}.${mimetype.split('/')[1]}`;
const size = buffer.byteLength;
const fullName = join(`${this.instance.id}`, messageRaw.key.remoteJid, mediaType, fileName);
await s3Service.uploadFile(fullName, buffer, size, {
'Content-Type': mimetype,
});
const mediaUrl = await s3Service.getObjectUrl(fullName);
messageRaw.message.mediaUrl = mediaUrl;
}
const fileName = `${messageRaw.key.id}.${mimetype.split('/')[1]}`;
const size = buffer.byteLength;
const fullName = join(`${this.instance.id}`, messageRaw.key.remoteJid, mediaType, fileName);
await s3Service.uploadFile(fullName, buffer, size, {
'Content-Type': mimetype,
});
const mediaUrl = await s3Service.getObjectUrl(fullName);
messageRaw.message.mediaUrl = mediaUrl;
} catch (error) {
this.logger.error(['Error on upload file to minio', error?.message, error?.stack]);
}

View File

@@ -429,107 +429,114 @@ export class BusinessStartupService extends ChannelStartupService {
try {
const message: any = received;
const id = message.messages[0][message.messages[0].type].id;
let urlServer = this.configService.get<WaBusiness>('WA_BUSINESS').URL;
const version = this.configService.get<WaBusiness>('WA_BUSINESS').VERSION;
urlServer = `${urlServer}/${version}/${id}`;
const headers = { 'Content-Type': 'application/json', Authorization: `Bearer ${this.token}` };
const result = await axios.get(urlServer, { headers });
// Verificação adicional para garantir que há conteúdo de mídia real
const hasRealMedia = this.hasValidMediaContent(messageRaw);
const buffer = await axios.get(result.data.url, {
headers: { Authorization: `Bearer ${this.token}` }, // Use apenas o token de autorização para download
responseType: 'arraybuffer',
});
let mediaType;
if (message.messages[0].document) {
mediaType = 'document';
} else if (message.messages[0].image) {
mediaType = 'image';
} else if (message.messages[0].audio) {
mediaType = 'audio';
if (!hasRealMedia) {
this.logger.warn('Message detected as media but contains no valid media content');
} else {
mediaType = 'video';
}
const id = message.messages[0][message.messages[0].type].id;
let urlServer = this.configService.get<WaBusiness>('WA_BUSINESS').URL;
const version = this.configService.get<WaBusiness>('WA_BUSINESS').VERSION;
urlServer = `${urlServer}/${version}/${id}`;
const headers = { 'Content-Type': 'application/json', Authorization: `Bearer ${this.token}` };
const result = await axios.get(urlServer, { headers });
const mimetype = result.data?.mime_type || result.headers['content-type'];
const buffer = await axios.get(result.data.url, {
headers: { Authorization: `Bearer ${this.token}` }, // Use apenas o token de autorização para download
responseType: 'arraybuffer',
});
const contentDisposition = result.headers['content-disposition'];
let fileName = `${message.messages[0].id}.${mimetype.split('/')[1]}`;
if (contentDisposition) {
const match = contentDisposition.match(/filename="(.+?)"/);
if (match) {
fileName = match[1];
let mediaType;
if (message.messages[0].document) {
mediaType = 'document';
} else if (message.messages[0].image) {
mediaType = 'image';
} else if (message.messages[0].audio) {
mediaType = 'audio';
} else {
mediaType = 'video';
}
}
// Para áudio, garantir extensão correta baseada no mimetype
if (mediaType === 'audio') {
if (mimetype.includes('ogg')) {
fileName = `${message.messages[0].id}.ogg`;
} else if (mimetype.includes('mp3')) {
fileName = `${message.messages[0].id}.mp3`;
} else if (mimetype.includes('m4a')) {
fileName = `${message.messages[0].id}.m4a`;
const mimetype = result.data?.mime_type || result.headers['content-type'];
const contentDisposition = result.headers['content-disposition'];
let fileName = `${message.messages[0].id}.${mimetype.split('/')[1]}`;
if (contentDisposition) {
const match = contentDisposition.match(/filename="(.+?)"/);
if (match) {
fileName = match[1];
}
}
}
const size = result.headers['content-length'] || buffer.data.byteLength;
// Para áudio, garantir extensão correta baseada no mimetype
if (mediaType === 'audio') {
if (mimetype.includes('ogg')) {
fileName = `${message.messages[0].id}.ogg`;
} else if (mimetype.includes('mp3')) {
fileName = `${message.messages[0].id}.mp3`;
} else if (mimetype.includes('m4a')) {
fileName = `${message.messages[0].id}.m4a`;
}
}
const fullName = join(`${this.instance.id}`, key.remoteJid, mediaType, fileName);
const size = result.headers['content-length'] || buffer.data.byteLength;
await s3Service.uploadFile(fullName, buffer.data, size, {
'Content-Type': mimetype,
});
const fullName = join(`${this.instance.id}`, key.remoteJid, mediaType, fileName);
const createdMessage = await this.prismaRepository.message.create({
data: messageRaw,
});
await s3Service.uploadFile(fullName, buffer.data, size, {
'Content-Type': mimetype,
});
await this.prismaRepository.media.create({
data: {
messageId: createdMessage.id,
instanceId: this.instanceId,
type: mediaType,
fileName: fullName,
mimetype,
},
});
const createdMessage = await this.prismaRepository.message.create({
data: messageRaw,
});
const mediaUrl = await s3Service.getObjectUrl(fullName);
messageRaw.message.mediaUrl = mediaUrl;
messageRaw.message.base64 = buffer.data.toString('base64');
// Processar OpenAI speech-to-text para áudio após o mediaUrl estar disponível
if (this.configService.get<Openai>('OPENAI').ENABLED && mediaType === 'audio') {
const openAiDefaultSettings = await this.prismaRepository.openaiSetting.findFirst({
where: {
await this.prismaRepository.media.create({
data: {
messageId: createdMessage.id,
instanceId: this.instanceId,
},
include: {
OpenaiCreds: true,
type: mediaType,
fileName: fullName,
mimetype,
},
});
if (
openAiDefaultSettings &&
openAiDefaultSettings.openaiCredsId &&
openAiDefaultSettings.speechToText
) {
try {
messageRaw.message.speechToText = `[audio] ${await this.openaiService.speechToText(
openAiDefaultSettings.OpenaiCreds,
{
message: {
mediaUrl: messageRaw.message.mediaUrl,
...messageRaw,
const mediaUrl = await s3Service.getObjectUrl(fullName);
messageRaw.message.mediaUrl = mediaUrl;
messageRaw.message.base64 = buffer.data.toString('base64');
// Processar OpenAI speech-to-text para áudio após o mediaUrl estar disponível
if (this.configService.get<Openai>('OPENAI').ENABLED && mediaType === 'audio') {
const openAiDefaultSettings = await this.prismaRepository.openaiSetting.findFirst({
where: {
instanceId: this.instanceId,
},
include: {
OpenaiCreds: true,
},
});
if (
openAiDefaultSettings &&
openAiDefaultSettings.openaiCredsId &&
openAiDefaultSettings.speechToText
) {
try {
messageRaw.message.speechToText = `[audio] ${await this.openaiService.speechToText(
openAiDefaultSettings.OpenaiCreds,
{
message: {
mediaUrl: messageRaw.message.mediaUrl,
...messageRaw,
},
},
},
)}`;
} catch (speechError) {
this.logger.error(`Error processing speech-to-text: ${speechError}`);
)}`;
} catch (speechError) {
this.logger.error(`Error processing speech-to-text: ${speechError}`);
}
}
}
}

View File

@@ -99,6 +99,7 @@ import makeWASocket, {
Contact,
delay,
DisconnectReason,
downloadContentFromMessage,
downloadMediaMessage,
generateWAMessageFromContent,
getAggregateVotesInPollMessage,
@@ -122,7 +123,7 @@ import makeWASocket, {
WABrowserDescription,
WAMediaUpload,
WAMessage,
WAMessageUpdate,
WAMessageKey,
WAPresence,
WASocket,
} from 'baileys';
@@ -894,7 +895,7 @@ export class BaileysStartupService extends ChannelStartupService {
}: {
chats: Chat[];
contacts: Contact[];
messages: proto.IWebMessageInfo[];
messages: WAMessage[];
isLatest?: boolean;
progress?: number;
syncType?: proto.HistorySync.HistorySyncType;
@@ -980,6 +981,10 @@ export class BaileysStartupService extends ChannelStartupService {
continue;
}
if (m.key.remoteJid?.includes('@lid') && m.key.senderPn) {
m.key.remoteJid = m.key.senderPn;
}
if (Long.isLong(m?.messageTimestamp)) {
m.messageTimestamp = m.messageTimestamp?.toNumber();
}
@@ -1037,11 +1042,29 @@ export class BaileysStartupService extends ChannelStartupService {
},
'messages.upsert': async (
{ messages, type, requestId }: { messages: proto.IWebMessageInfo[]; type: MessageUpsertType; requestId?: string },
{ messages, type, requestId }: { messages: WAMessage[]; type: MessageUpsertType; requestId?: string },
settings: any,
) => {
try {
for (const received of messages) {
if (received.key.remoteJid?.includes('@lid') && received.key.senderPn) {
(received.key as { previousRemoteJid?: string | null }).previousRemoteJid = received.key.remoteJid;
received.key.remoteJid = received.key.senderPn;
}
if (
received?.messageStubParameters?.some?.((param) =>
[
'No matching sessions found for message',
'Bad MAC',
'failed to decrypt message',
'SessionError',
'Invalid PreKey ID',
].some((err) => param?.includes?.(err)),
)
) {
this.logger.warn(`Message ignored with messageStubParameters: ${JSON.stringify(received, null, 2)}`);
continue;
}
if (received.message?.conversation || received.message?.extendedTextMessage?.text) {
const text = received.message?.conversation || received.message?.extendedTextMessage?.text;
@@ -1233,33 +1256,41 @@ export class BaileysStartupService extends ChannelStartupService {
if (this.configService.get<S3>('S3').ENABLE) {
try {
const message: any = received;
const media = await this.getBase64FromMediaMessage({ message }, true);
const { buffer, mediaType, fileName, size } = media;
const mimetype = mimeTypes.lookup(fileName).toString();
const fullName = join(
`${this.instance.id}`,
received.key.remoteJid,
mediaType,
`${Date.now()}_${fileName}`,
);
await s3Service.uploadFile(fullName, buffer, size.fileLength?.low, { 'Content-Type': mimetype });
// Verificação adicional para garantir que há conteúdo de mídia real
const hasRealMedia = this.hasValidMediaContent(message);
await this.prismaRepository.media.create({
data: {
messageId: msg.id,
instanceId: this.instanceId,
type: mediaType,
fileName: fullName,
mimetype,
},
});
if (!hasRealMedia) {
this.logger.warn('Message detected as media but contains no valid media content');
} else {
const media = await this.getBase64FromMediaMessage({ message }, true);
const mediaUrl = await s3Service.getObjectUrl(fullName);
const { buffer, mediaType, fileName, size } = media;
const mimetype = mimeTypes.lookup(fileName).toString();
const fullName = join(
`${this.instance.id}`,
received.key.remoteJid,
mediaType,
`${Date.now()}_${fileName}`,
);
await s3Service.uploadFile(fullName, buffer, size.fileLength?.low, { 'Content-Type': mimetype });
messageRaw.message.mediaUrl = mediaUrl;
await this.prismaRepository.media.create({
data: {
messageId: msg.id,
instanceId: this.instanceId,
type: mediaType,
fileName: fullName,
mimetype,
},
});
await this.prismaRepository.message.update({ where: { id: msg.id }, data: messageRaw });
const mediaUrl = await s3Service.getObjectUrl(fullName);
messageRaw.message.mediaUrl = mediaUrl;
await this.prismaRepository.message.update({ where: { id: msg.id }, data: messageRaw });
}
} catch (error) {
this.logger.error(['Error on upload file to minio', error?.message, error?.stack]);
}
@@ -1363,7 +1394,7 @@ export class BaileysStartupService extends ChannelStartupService {
}
},
'messages.update': async (args: WAMessageUpdate[], settings: any) => {
'messages.update': async (args: { update: Partial<WAMessage>; key: WAMessageKey }[], settings: any) => {
this.logger.log(`Update messages ${JSON.stringify(args, undefined, 2)}`);
const readChatToUpdate: Record<string, true> = {}; // {remoteJid: true}
@@ -1373,6 +1404,10 @@ export class BaileysStartupService extends ChannelStartupService {
continue;
}
if (key.remoteJid?.includes('@lid') && key.senderPn) {
key.remoteJid = key.senderPn;
}
const updateKey = `${this.instance.id}_${key.id}_${update.status}`;
const cached = await this.baileysCache.get(updateKey);
@@ -2130,31 +2165,39 @@ export class BaileysStartupService extends ChannelStartupService {
if (isMedia && this.configService.get<S3>('S3').ENABLE) {
try {
const message: any = messageRaw;
const media = await this.getBase64FromMediaMessage({ message }, true);
const { buffer, mediaType, fileName, size } = media;
// Verificação adicional para garantir que há conteúdo de mídia real
const hasRealMedia = this.hasValidMediaContent(message);
const mimetype = mimeTypes.lookup(fileName).toString();
if (!hasRealMedia) {
this.logger.warn('Message detected as media but contains no valid media content');
} else {
const media = await this.getBase64FromMediaMessage({ message }, true);
const fullName = join(
`${this.instance.id}`,
messageRaw.key.remoteJid,
`${messageRaw.key.id}`,
mediaType,
fileName,
);
const { buffer, mediaType, fileName, size } = media;
await s3Service.uploadFile(fullName, buffer, size.fileLength?.low, { 'Content-Type': mimetype });
const mimetype = mimeTypes.lookup(fileName).toString();
await this.prismaRepository.media.create({
data: { messageId: msg.id, instanceId: this.instanceId, type: mediaType, fileName: fullName, mimetype },
});
const fullName = join(
`${this.instance.id}`,
messageRaw.key.remoteJid,
`${messageRaw.key.id}`,
mediaType,
fileName,
);
const mediaUrl = await s3Service.getObjectUrl(fullName);
await s3Service.uploadFile(fullName, buffer, size.fileLength?.low, { 'Content-Type': mimetype });
messageRaw.message.mediaUrl = mediaUrl;
await this.prismaRepository.media.create({
data: { messageId: msg.id, instanceId: this.instanceId, type: mediaType, fileName: fullName, mimetype },
});
await this.prismaRepository.message.update({ where: { id: msg.id }, data: messageRaw });
const mediaUrl = await s3Service.getObjectUrl(fullName);
messageRaw.message.mediaUrl = mediaUrl;
await this.prismaRepository.message.update({ where: { id: msg.id }, data: messageRaw });
}
} catch (error) {
this.logger.error(['Error on upload file to minio', error?.message, error?.stack]);
}
@@ -3422,6 +3465,18 @@ export class BaileysStartupService extends ChannelStartupService {
}
}
public async mapMediaType(mediaType) {
const map = {
imageMessage: 'image',
videoMessage: 'video',
documentMessage: 'document',
stickerMessage: 'sticker',
audioMessage: 'audio',
ptvMessage: 'video',
};
return map[mediaType] || null;
}
public async getBase64FromMediaMessage(data: getBase64FromMediaMessageDto, getBuffer = false) {
try {
const m = data?.message;
@@ -3446,28 +3501,76 @@ export class BaileysStartupService extends ChannelStartupService {
let mediaMessage: any;
let mediaType: string;
for (const type of TypeMediaMessage) {
mediaMessage = msg.message[type];
if (mediaMessage) {
mediaType = type;
break;
}
}
if (msg.message?.templateMessage) {
const template =
msg.message.templateMessage.hydratedTemplate || msg.message.templateMessage.hydratedFourRowTemplate;
if (!mediaMessage) {
throw 'The message is not of the media type';
for (const type of TypeMediaMessage) {
if (template[type]) {
mediaMessage = template[type];
mediaType = type;
msg.message = { [type]: { ...template[type], url: template[type].staticUrl } };
break;
}
}
if (!mediaMessage) {
throw 'Template message does not contain a supported media type';
}
} else {
for (const type of TypeMediaMessage) {
mediaMessage = msg.message[type];
if (mediaMessage) {
mediaType = type;
break;
}
}
if (!mediaMessage) {
throw 'The message is not of the media type';
}
}
if (typeof mediaMessage['mediaKey'] === 'object') {
msg.message = JSON.parse(JSON.stringify(msg.message));
}
const buffer = await downloadMediaMessage(
{ key: msg?.key, message: msg?.message },
'buffer',
{},
{ logger: P({ level: 'error' }) as any, reuploadRequest: this.client.updateMediaMessage },
);
let buffer: Buffer;
try {
buffer = await downloadMediaMessage(
{ key: msg?.key, message: msg?.message },
'buffer',
{},
{ logger: P({ level: 'error' }) as any, reuploadRequest: this.client.updateMediaMessage },
);
} catch (err) {
this.logger.error('Download Media failed, trying to retry in 5 seconds...');
await new Promise((resolve) => setTimeout(resolve, 5000));
const mediaType = Object.keys(msg.message).find((key) => key.endsWith('Message'));
if (!mediaType) throw new Error('Could not determine mediaType for fallback');
try {
const media = await downloadContentFromMessage(
{
mediaKey: msg.message?.[mediaType]?.mediaKey,
directPath: msg.message?.[mediaType]?.directPath,
url: `https://mmg.whatsapp.net${msg?.message?.[mediaType]?.directPath}`,
},
await this.mapMediaType(mediaType),
{},
);
const chunks = [];
for await (const chunk of media) {
chunks.push(chunk);
}
buffer = Buffer.concat(chunks);
this.logger.info('Download Media with downloadContentFromMessage was successful!');
} catch (fallbackErr) {
this.logger.error('Download Media with downloadContentFromMessage also failed!');
throw fallbackErr;
}
}
const typeMessage = getContentType(msg.message);
const ext = mimeTypes.extension(mediaMessage?.['mimetype']);

View File

@@ -26,7 +26,7 @@ import axios from 'axios';
import { proto } from 'baileys';
import dayjs from 'dayjs';
import FormData from 'form-data';
import Jimp from 'jimp';
import { Jimp, JimpMime } from 'jimp';
import Long from 'long';
import mimeTypes from 'mime-types';
import path from 'path';
@@ -457,6 +457,24 @@ export class ChatwootService {
}
}
private async mergeContacts(baseId: number, mergeId: number) {
try {
const contact = await chatwootRequest(this.getClientCwConfig(), {
method: 'POST',
url: `/api/v1/accounts/${this.provider.accountId}/actions/contact_merge`,
body: {
base_contact_id: baseId,
mergee_contact_id: mergeId,
},
});
return contact;
} catch {
this.logger.error('Error merging contacts');
return null;
}
}
private async mergeBrazilianContacts(contacts: any[]) {
try {
const contact = await chatwootRequest(this.getClientCwConfig(), {
@@ -549,24 +567,34 @@ export class ChatwootService {
}
public async createConversation(instance: InstanceDto, body: any) {
const isLid = body.key.remoteJid.includes('@lid') && body.key.senderPn;
const remoteJid = isLid ? body.key.senderPn : body.key.remoteJid;
const isLid = body.key.previousRemoteJid?.includes('@lid') && body.key.senderPn;
const remoteJid = body.key.remoteJid;
const cacheKey = `${instance.instanceName}:createConversation-${remoteJid}`;
const lockKey = `${instance.instanceName}:lock:createConversation-${remoteJid}`;
const maxWaitTime = 5000; // 5 secounds
try {
// Processa atualização de contatos já criados @lid
if (body.key.remoteJid.includes('@lid') && body.key.senderPn && body.key.senderPn !== body.key.remoteJid) {
if (isLid && body.key.senderPn !== body.key.previousRemoteJid) {
const contact = await this.findContact(instance, body.key.remoteJid.split('@')[0]);
if (contact && contact.identifier !== body.key.senderPn) {
this.logger.verbose(
`Identifier needs update: (contact.identifier: ${contact.identifier}, body.key.remoteJid: ${body.key.remoteJid}, body.key.senderPn: ${body.key.senderPn})`,
`Identifier needs update: (contact.identifier: ${contact.identifier}, body.key.remoteJid: ${body.key.remoteJid}, body.key.senderPn: ${body.key.senderPn}`,
);
await this.updateContact(instance, contact.id, {
const updateContact = await this.updateContact(instance, contact.id, {
identifier: body.key.senderPn,
phone_number: `+${body.key.senderPn.split('@')[0]}`,
});
if (updateContact === null) {
const baseContact = await this.findContact(instance, body.key.senderPn.split('@')[0]);
if (baseContact) {
await this.mergeContacts(baseContact.id, contact.id);
this.logger.verbose(
`Merge contacts: (${baseContact.id}) ${baseContact.phone_number} and (${contact.id}) ${contact.phone_number}`,
);
}
}
}
}
this.logger.verbose(`--- Start createConversation ---`);
@@ -685,7 +713,6 @@ export class ChatwootService {
}
}
} else {
const jid = isLid && body?.key?.senderPn ? body.key.senderPn : body.key.remoteJid;
contact = await this.createContact(
instance,
chatId,
@@ -693,7 +720,7 @@ export class ChatwootService {
isGroup,
nameContact,
picture_url.profilePictureUrl || null,
jid,
remoteJid,
);
}
@@ -2101,9 +2128,11 @@ export class ChatwootService {
const fileData = Buffer.from(imgBuffer.data, 'binary');
const img = await Jimp.read(fileData);
await img.cover(320, 180);
const processedBuffer = await img.getBufferAsync(Jimp.MIME_PNG);
await img.cover({
w: 320,
h: 180,
});
const processedBuffer = await img.getBuffer(JimpMime.png);
const fileStream = new Readable();
fileStream._read = () => {}; // _read is required but you can noop it

View File

@@ -70,7 +70,7 @@ export class EvoaiService extends BaseChatbotService<Evoai, EvoaiSetting> {
}
const callId = `req-${uuidv4().substring(0, 8)}`;
const messageId = msg?.key?.id || uuidv4();
const messageId = remoteJid.split('@')[0] || uuidv4(); // Use phone number as messageId
// Prepare message parts
const parts = [

View File

@@ -119,7 +119,7 @@ export class TypebotController extends BaseChatbotController<TypebotModel, Typeb
const instanceData = await this.prismaRepository.instance.findFirst({
where: {
id: instance.instanceId,
name: instance.instanceName,
},
});
@@ -290,7 +290,7 @@ export class TypebotController extends BaseChatbotController<TypebotModel, Typeb
request.data.clientSideActions,
);
this.waMonitor.waInstances[instance.instanceId].sendDataWebhook(Events.TYPEBOT_START, {
this.waMonitor.waInstances[instance.instanceName].sendDataWebhook(Events.TYPEBOT_START, {
remoteJid: remoteJid,
url: url,
typebot: typebot,

View File

@@ -186,7 +186,7 @@ export class TypebotService extends BaseChatbotService<TypebotModel, any> {
messages,
input,
clientSideActions,
this.applyFormatting,
this.applyFormatting.bind(this),
this.prismaRepository,
).catch((err) => {
console.error('Erro ao processar mensagens:', err);

View File

@@ -8,7 +8,12 @@ import { EmitData, EventController, EventControllerInterface } from '../event.co
export class RabbitmqController extends EventController implements EventControllerInterface {
public amqpChannel: amqp.Channel | null = null;
private amqpConnection: amqp.Connection | null = null;
private readonly logger = new Logger('RabbitmqController');
private reconnectAttempts = 0;
private maxReconnectAttempts = 10;
private reconnectDelay = 5000; // 5 seconds
private isReconnecting = false;
constructor(prismaRepository: PrismaRepository, waMonitor: WAMonitoringService) {
super(prismaRepository, waMonitor, configService.get<Rabbitmq>('RABBITMQ')?.ENABLED, 'rabbitmq');
@@ -19,7 +24,11 @@ export class RabbitmqController extends EventController implements EventControll
return;
}
await new Promise<void>((resolve, reject) => {
await this.connect();
}
private async connect(): Promise<void> {
return new Promise<void>((resolve, reject) => {
const uri = configService.get<Rabbitmq>('RABBITMQ').URI;
const frameMax = configService.get<Rabbitmq>('RABBITMQ').FRAME_MAX;
const rabbitmqExchangeName = configService.get<Rabbitmq>('RABBITMQ').EXCHANGE_NAME;
@@ -33,22 +42,61 @@ export class RabbitmqController extends EventController implements EventControll
password: url.password || 'guest',
vhost: url.pathname.slice(1) || '/',
frameMax: frameMax,
heartbeat: 30, // Add heartbeat of 30 seconds
};
amqp.connect(connectionOptions, (error, connection) => {
if (error) {
this.logger.error({
local: 'RabbitmqController.connect',
message: 'Failed to connect to RabbitMQ',
error: error.message || error,
});
reject(error);
return;
}
// Connection event handlers
connection.on('error', (err) => {
this.logger.error({
local: 'RabbitmqController.connectionError',
message: 'RabbitMQ connection error',
error: err.message || err,
});
this.handleConnectionLoss();
});
connection.on('close', () => {
this.logger.warn('RabbitMQ connection closed');
this.handleConnectionLoss();
});
connection.createChannel((channelError, channel) => {
if (channelError) {
this.logger.error({
local: 'RabbitmqController.createChannel',
message: 'Failed to create RabbitMQ channel',
error: channelError.message || channelError,
});
reject(channelError);
return;
}
// Channel event handlers
channel.on('error', (err) => {
this.logger.error({
local: 'RabbitmqController.channelError',
message: 'RabbitMQ channel error',
error: err.message || err,
});
this.handleConnectionLoss();
});
channel.on('close', () => {
this.logger.warn('RabbitMQ channel closed');
this.handleConnectionLoss();
});
const exchangeName = rabbitmqExchangeName;
channel.assertExchange(exchangeName, 'topic', {
@@ -56,16 +104,81 @@ export class RabbitmqController extends EventController implements EventControll
autoDelete: false,
});
this.amqpConnection = connection;
this.amqpChannel = channel;
this.reconnectAttempts = 0; // Reset reconnect attempts on successful connection
this.isReconnecting = false;
this.logger.info('AMQP initialized');
this.logger.info('AMQP initialized successfully');
resolve();
});
});
}).then(() => {
if (configService.get<Rabbitmq>('RABBITMQ')?.GLOBAL_ENABLED) this.initGlobalQueues();
});
})
.then(() => {
if (configService.get<Rabbitmq>('RABBITMQ')?.GLOBAL_ENABLED) {
this.initGlobalQueues();
}
})
.catch((error) => {
this.logger.error({
local: 'RabbitmqController.init',
message: 'Failed to initialize AMQP',
error: error.message || error,
});
this.scheduleReconnect();
throw error;
});
}
private handleConnectionLoss(): void {
if (this.isReconnecting) {
return; // Already attempting to reconnect
}
this.amqpChannel = null;
this.amqpConnection = null;
this.scheduleReconnect();
}
private scheduleReconnect(): void {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
this.logger.error(
`Maximum reconnect attempts (${this.maxReconnectAttempts}) reached. Stopping reconnection attempts.`,
);
return;
}
if (this.isReconnecting) {
return; // Already scheduled
}
this.isReconnecting = true;
this.reconnectAttempts++;
const delay = this.reconnectDelay * Math.pow(2, Math.min(this.reconnectAttempts - 1, 5)); // Exponential backoff with max delay
this.logger.info(
`Scheduling RabbitMQ reconnection attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts} in ${delay}ms`,
);
setTimeout(async () => {
try {
this.logger.info(
`Attempting to reconnect to RabbitMQ (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`,
);
await this.connect();
this.logger.info('Successfully reconnected to RabbitMQ');
} catch (error) {
this.logger.error({
local: 'RabbitmqController.scheduleReconnect',
message: `Reconnection attempt ${this.reconnectAttempts} failed`,
error: error.message || error,
});
this.isReconnecting = false;
this.scheduleReconnect();
}
}, delay);
}
private set channel(channel: amqp.Channel) {
@@ -76,6 +189,17 @@ export class RabbitmqController extends EventController implements EventControll
return this.amqpChannel;
}
private async ensureConnection(): Promise<boolean> {
if (!this.amqpChannel) {
this.logger.warn('AMQP channel is not available, attempting to reconnect...');
if (!this.isReconnecting) {
this.scheduleReconnect();
}
return false;
}
return true;
}
public async emit({
instanceName,
origin,
@@ -95,6 +219,11 @@ export class RabbitmqController extends EventController implements EventControll
return;
}
if (!(await this.ensureConnection())) {
this.logger.warn(`Failed to emit event ${event} for instance ${instanceName}: No AMQP connection`);
return;
}
const instanceRabbitmq = await this.get(instanceName);
const rabbitmqLocal = instanceRabbitmq?.events;
const rabbitmqGlobal = configService.get<Rabbitmq>('RABBITMQ').GLOBAL_ENABLED;
@@ -154,7 +283,15 @@ export class RabbitmqController extends EventController implements EventControll
break;
} catch (error) {
this.logger.error({
local: 'RabbitmqController.emit',
message: `Error publishing local RabbitMQ message (attempt ${retry + 1}/3)`,
error: error.message || error,
});
retry++;
if (retry >= 3) {
this.handleConnectionLoss();
}
}
}
}
@@ -199,7 +336,15 @@ export class RabbitmqController extends EventController implements EventControll
break;
} catch (error) {
this.logger.error({
local: 'RabbitmqController.emit',
message: `Error publishing global RabbitMQ message (attempt ${retry + 1}/3)`,
error: error.message || error,
});
retry++;
if (retry >= 3) {
this.handleConnectionLoss();
}
}
}
}
@@ -208,41 +353,57 @@ export class RabbitmqController extends EventController implements EventControll
private async initGlobalQueues(): Promise<void> {
this.logger.info('Initializing global queues');
if (!(await this.ensureConnection())) {
this.logger.error('Cannot initialize global queues: No AMQP connection');
return;
}
const rabbitmqExchangeName = configService.get<Rabbitmq>('RABBITMQ').EXCHANGE_NAME;
const events = configService.get<Rabbitmq>('RABBITMQ').EVENTS;
const prefixKey = configService.get<Rabbitmq>('RABBITMQ').PREFIX_KEY;
if (!events) {
this.logger.warn('No events to initialize on AMQP');
return;
}
const eventKeys = Object.keys(events);
eventKeys.forEach((event) => {
if (events[event] === false) return;
for (const event of eventKeys) {
if (events[event] === false) continue;
const queueName =
prefixKey !== ''
? `${prefixKey}.${event.replace(/_/g, '.').toLowerCase()}`
: `${event.replace(/_/g, '.').toLowerCase()}`;
const exchangeName = rabbitmqExchangeName;
try {
const queueName =
prefixKey !== ''
? `${prefixKey}.${event.replace(/_/g, '.').toLowerCase()}`
: `${event.replace(/_/g, '.').toLowerCase()}`;
const exchangeName = rabbitmqExchangeName;
this.amqpChannel.assertExchange(exchangeName, 'topic', {
durable: true,
autoDelete: false,
});
await this.amqpChannel.assertExchange(exchangeName, 'topic', {
durable: true,
autoDelete: false,
});
this.amqpChannel.assertQueue(queueName, {
durable: true,
autoDelete: false,
arguments: {
'x-queue-type': 'quorum',
},
});
await this.amqpChannel.assertQueue(queueName, {
durable: true,
autoDelete: false,
arguments: {
'x-queue-type': 'quorum',
},
});
this.amqpChannel.bindQueue(queueName, exchangeName, event);
});
await this.amqpChannel.bindQueue(queueName, exchangeName, event);
this.logger.info(`Global queue initialized: ${queueName}`);
} catch (error) {
this.logger.error({
local: 'RabbitmqController.initGlobalQueues',
message: `Failed to initialize global queue for event ${event}`,
error: error.message || error,
});
this.handleConnectionLoss();
break;
}
}
}
}

View File

@@ -738,6 +738,7 @@ export class ChannelStartupService {
"Chat"."name" as "pushName",
"Chat"."createdAt" as "windowStart",
"Chat"."createdAt" + INTERVAL '24 hours' as "windowExpires",
"Chat"."unreadMessages" as "unreadMessages",
CASE WHEN "Chat"."createdAt" + INTERVAL '24 hours' > NOW() THEN true ELSE false END as "windowActive",
"Message"."id" AS lastMessageId,
"Message"."key" AS lastMessage_key,
@@ -797,7 +798,7 @@ export class ChannelStartupService {
windowExpires: contact.windowexpires,
windowActive: contact.windowactive,
lastMessage: lastMessage ? this.cleanMessageData(lastMessage) : undefined,
unreadCount: 0,
unreadCount: contact.unreadMessages,
isSaved: !!contact.contactid,
};
});
@@ -813,4 +814,28 @@ export class ChannelStartupService {
return [];
}
public hasValidMediaContent(message: any): boolean {
if (!message?.message) return false;
const msg = message.message;
// Se só tem messageContextInfo, não é mídia válida
if (Object.keys(msg).length === 1 && 'messageContextInfo' in msg) {
return false;
}
// Verifica se tem pelo menos um tipo de mídia válido
const mediaTypes = [
'imageMessage',
'videoMessage',
'stickerMessage',
'documentMessage',
'documentWithCaptionMessage',
'ptvMessage',
'audioMessage',
];
return mediaTypes.some((type) => msg[type] && Object.keys(msg[type]).length > 0);
}
}

19
src/railway.json Normal file
View File

@@ -0,0 +1,19 @@
{
"$schema": "https://railway.com/railway.schema.json",
"build": {
"builder": "DOCKERFILE",
"dockerfilePath": "Dockerfile"
},
"deploy": {
"runtime": "V2",
"numReplicas": 1,
"sleepApplication": false,
"multiRegionConfig": {
"us-east4-eqdc4a": {
"numReplicas": 1
}
},
"restartPolicyType": "ON_FAILURE",
"restartPolicyMaxRetries": 10
}
}