import { InstanceDto } from '@api/dto/instance.dto'; import { Options, Quoted, SendAudioDto, SendMediaDto, SendTextDto } from '@api/dto/sendMessage.dto'; import { ChatwootDto } from '@api/integrations/chatbot/chatwoot/dto/chatwoot.dto'; import { postgresClient } from '@api/integrations/chatbot/chatwoot/libs/postgres.client'; import { chatwootImport } from '@api/integrations/chatbot/chatwoot/utils/chatwoot-import-helper'; import { PrismaRepository } from '@api/repository/repository.service'; import { CacheService } from '@api/services/cache.service'; import { WAMonitoringService } from '@api/services/monitor.service'; import { Events } from '@api/types/wa.types'; import { Chatwoot, ConfigService, Database, HttpServer } from '@config/env.config'; import { Logger } from '@config/logger.config'; import ChatwootClient, { ChatwootAPIConfig, contact, contact_inboxes, conversation, conversation_show, generic_id, inbox, } from '@figuro/chatwoot-sdk'; import { request as chatwootRequest } from '@figuro/chatwoot-sdk/dist/core/request'; import { Chatwoot as ChatwootModel, Contact as ContactModel, Message as MessageModel } from '@prisma/client'; import i18next from '@utils/i18n'; import { sendTelemetry } from '@utils/sendTelemetry'; import axios from 'axios'; import { proto } from 'baileys'; import dayjs from 'dayjs'; import FormData from 'form-data'; import Jimp from 'jimp'; import Long from 'long'; import mimeTypes from 'mime-types'; import path from 'path'; import { Readable } from 'stream'; interface ChatwootMessage { messageId?: number; inboxId?: number; conversationId?: number; contactInboxSourceId?: string; isRead?: boolean; } export class ChatwootService { private readonly logger = new Logger('ChatwootService'); private provider: any; constructor( private readonly waMonitor: WAMonitoringService, private readonly configService: ConfigService, private readonly prismaRepository: PrismaRepository, private readonly cache: CacheService, ) { } private pgClient = postgresClient.getChatwootConnection(); private async getProvider(instance: InstanceDto): Promise { const cacheKey = `${instance.instanceName}:getProvider`; if (await this.cache.has(cacheKey)) { const provider = (await this.cache.get(cacheKey)) as ChatwootModel; return provider; } const provider = await this.waMonitor.waInstances[instance.instanceName]?.findChatwoot(); if (!provider) { this.logger.warn('provider not found'); return null; } this.cache.set(cacheKey, provider); return provider; } private async clientCw(instance: InstanceDto) { const provider = await this.getProvider(instance); if (!provider) { this.logger.error('provider not found'); return null; } this.provider = provider; const client = new ChatwootClient({ config: this.getClientCwConfig(), }); return client; } public getClientCwConfig(): ChatwootAPIConfig & { nameInbox: string; mergeBrazilContacts: boolean } { return { basePath: this.provider.url, with_credentials: true, credentials: 'include', token: this.provider.token, nameInbox: this.provider.nameInbox, mergeBrazilContacts: this.provider.mergeBrazilContacts, }; } public getCache() { return this.cache; } public async create(instance: InstanceDto, data: ChatwootDto) { await this.waMonitor.waInstances[instance.instanceName].setChatwoot(data); if (data.autoCreate) { this.logger.log('Auto create chatwoot instance'); const urlServer = this.configService.get('SERVER').URL; await this.initInstanceChatwoot( instance, data.nameInbox ?? instance.instanceName.split('-cwId-')[0], `${urlServer}/chatwoot/webhook/${encodeURIComponent(instance.instanceName)}`, true, data.number, data.organization, data.logo, ); } return data; } public async find(instance: InstanceDto): Promise { try { return await this.waMonitor.waInstances[instance.instanceName].findChatwoot(); } catch (error) { this.logger.error('chatwoot not found'); return { enabled: null, url: '' }; } } public async getContact(instance: InstanceDto, id: number) { const client = await this.clientCw(instance); if (!client) { this.logger.warn('client not found'); return null; } if (!id) { this.logger.warn('id is required'); return null; } const contact = await client.contact.getContactable({ accountId: this.provider.accountId, id, }); if (!contact) { this.logger.warn('contact not found'); return null; } return contact; } public async initInstanceChatwoot( instance: InstanceDto, inboxName: string, webhookUrl: string, qrcode: boolean, number: string, organization?: string, logo?: string, ) { const client = await this.clientCw(instance); if (!client) { this.logger.warn('client not found'); return null; } const findInbox: any = await client.inboxes.list({ accountId: this.provider.accountId, }); const checkDuplicate = findInbox.payload.map((inbox) => inbox.name).includes(inboxName); let inboxId: number; this.logger.log('Creating chatwoot inbox'); if (!checkDuplicate) { const data = { type: 'api', webhook_url: webhookUrl, }; const inbox = await client.inboxes.create({ accountId: this.provider.accountId, data: { name: inboxName, channel: data as any, }, }); if (!inbox) { this.logger.warn('inbox not found'); return null; } inboxId = inbox.id; } else { const inbox = findInbox.payload.find((inbox) => inbox.name === inboxName); if (!inbox) { this.logger.warn('inbox not found'); return null; } inboxId = inbox.id; } this.logger.log(`Inbox created - inboxId: ${inboxId}`); if (!this.configService.get('CHATWOOT').BOT_CONTACT) { this.logger.log('Chatwoot bot contact is disabled'); return true; } this.logger.log('Creating chatwoot bot contact'); const contact = (await this.findContact(instance, '123456')) || ((await this.createContact( instance, '123456', inboxId, false, organization ? organization : 'EvolutionAPI', logo ? logo : 'https://evolution-api.com/files/evolution-api-favicon.png', )) as any); if (!contact) { this.logger.warn('contact not found'); return null; } const contactId = contact.id || contact.payload.contact.id; this.logger.log(`Contact created - contactId: ${contactId}`); if (qrcode) { this.logger.log('QR code enabled'); const data = { contact_id: contactId.toString(), inbox_id: inboxId.toString(), }; const conversation = await client.conversations.create({ accountId: this.provider.accountId, data, }); if (!conversation) { this.logger.warn('conversation not found'); return null; } let contentMsg = 'init'; if (number) { contentMsg = `init:${number}`; } const message = await client.messages.create({ accountId: this.provider.accountId, conversationId: conversation.id, data: { content: contentMsg, message_type: 'outgoing', }, }); if (!message) { this.logger.warn('conversation not found'); return null; } this.logger.log('Init message sent'); } return true; } public async createContact( instance: InstanceDto, phoneNumber: string, inboxId: number, isGroup: boolean, name?: string, avatar_url?: string, jid?: string, ) { this.logger.verbose( `[ChatwootService][createContact] start instance=${instance.instanceName} phone=${phoneNumber}` ); // 1) obter cliente const client = await this.clientCw(instance); if (!client) { this.logger.warn( `[ChatwootService][createContact] client not found for instance=${instance.instanceName}` ); return null; } this.logger.verbose(`[ChatwootService][createContact] client obtained`); // 2) montar payload const data: any = { inbox_id: inboxId, name: name || phoneNumber, avatar_url }; if (!isGroup) { data.identifier = jid; data.phone_number = `+${phoneNumber}`; } else { data.identifier = phoneNumber; } this.logger.verbose( `[ChatwootService][createContact] payload=${JSON.stringify(data)}` ); // 3) criar no Chatwoot let rawResponse: any; try { rawResponse = await client.contacts.create({ accountId: this.provider.accountId, data, }); this.logger.verbose( `[ChatwootService][createContact] raw create response=${JSON.stringify(rawResponse)}` ); } catch (err) { this.logger.error( `[ChatwootService][createContact] error creating contact: ${err}` ); throw err; } // 4) extrair o contactId dos dois possíveis formatos // - legacy: { id: number, ... } // - nova versão: { payload: { contact: { id: number, ... } } } const maybePayload = rawResponse.payload?.contact; const contactObj = maybePayload ?? rawResponse; const contactId = contactObj.id as number | undefined; if (!contactId) { this.logger.error( `[ChatwootService][createContact] no id found in response; raw=${JSON.stringify(rawResponse)}` ); return null; } this.logger.verbose( `[ChatwootService][createContact] created contact id=${contactId}` ); // 5) adicionar label try { this.logger.verbose( `[ChatwootService][createContact] adding label=${this.provider.nameInbox} to contactId=${contactId}` ); await this.addLabelToContact(this.provider.nameInbox, contactId); } catch (err) { this.logger.error( `[ChatwootService][createContact] error addLabelToContact: ${err}` ); } // 6) retornar objeto com .id para ser usado pelo createConversation return { id: contactId, ...contactObj }; } public async updateContact(instance: InstanceDto, id: number, data: any) { const client = await this.clientCw(instance); if (!client) { this.logger.warn('client not found'); return null; } if (!id) { this.logger.warn('id is required'); return null; } try { const contact = await client.contacts.update({ accountId: this.provider.accountId, id, data, }); return contact; } catch (error) { return null; } } public async addLabelToContact(nameInbox: string, contactId: number) { try { const uri = this.configService.get('CHATWOOT').IMPORT.DATABASE.CONNECTION.URI; if (!uri) return false; const sqlTags = `SELECT id, taggings_count FROM tags WHERE name = $1 LIMIT 1`; const tagData = (await this.pgClient.query(sqlTags, [nameInbox]))?.rows[0]; let tagId = tagData?.id; const taggingsCount = tagData?.taggings_count || 0; const sqlTag = `INSERT INTO tags (name, taggings_count) VALUES ($1, $2) ON CONFLICT (name) DO UPDATE SET taggings_count = tags.taggings_count + 1 RETURNING id`; tagId = (await this.pgClient.query(sqlTag, [nameInbox, taggingsCount + 1]))?.rows[0]?.id; const sqlCheckTagging = `SELECT 1 FROM taggings WHERE tag_id = $1 AND taggable_type = 'Contact' AND taggable_id = $2 AND context = 'labels' LIMIT 1`; const taggingExists = (await this.pgClient.query(sqlCheckTagging, [tagId, contactId]))?.rowCount > 0; if (!taggingExists) { const sqlInsertLabel = `INSERT INTO taggings (tag_id, taggable_type, taggable_id, context, created_at) VALUES ($1, 'Contact', $2, 'labels', NOW())`; await this.pgClient.query(sqlInsertLabel, [tagId, contactId]); } return true; } catch (error) { return false; } } public async findContact(instance: InstanceDto, phoneNumber: string) { this.logger.verbose( `[ChatwootService][findContact] start for instance=${instance.instanceName}, phoneNumber=${phoneNumber}` ); const client = await this.clientCw(instance); if (!client) { this.logger.warn( `[ChatwootService][findContact] client not found for instance=${instance.instanceName}` ); return null; } const isGroup = phoneNumber.includes('@g.us'); const query = isGroup ? phoneNumber : `+${phoneNumber}`; this.logger.verbose( `[ChatwootService][findContact] isGroup=${isGroup}, query=${query}` ); let response: any; try { if (isGroup) { response = await client.contacts.search({ accountId: this.provider.accountId, q: query, }); } else { response = await chatwootRequest(this.getClientCwConfig(), { method: 'POST', url: `/api/v1/accounts/${this.provider.accountId}/contacts/filter`, body: { payload: this.getFilterPayload(query) }, }); } this.logger.verbose( `[ChatwootService][findContact] raw response: ${JSON.stringify(response)}` ); } catch (error) { this.logger.error( `[ChatwootService][findContact] error during API call: ${error.message}` ); return null; } const payload = response.payload || []; this.logger.verbose( `[ChatwootService][findContact] payload length: ${payload.length}` ); if (payload.length === 0) { this.logger.warn( `[ChatwootService][findContact] contact not found for query=${query}` ); return null; } let found: any; if (isGroup) { found = payload.find((c: any) => c.identifier === query); } else { found = payload.length > 1 ? this.findContactInContactList(payload, query) : payload[0]; } this.logger.verbose( `[ChatwootService][findContact] returning contact: ${JSON.stringify(found)}` ); return found; } private async mergeBrazilianContacts(contacts: any[]) { try { const contact = await chatwootRequest(this.getClientCwConfig(), { method: 'POST', url: `/api/v1/accounts/${this.provider.accountId}/actions/contact_merge`, body: { base_contact_id: contacts.find((contact) => contact.phone_number.length === 14)?.id, mergee_contact_id: contacts.find((contact) => contact.phone_number.length === 13)?.id, }, }); return contact; } catch { this.logger.error('Error merging contacts'); return null; } } private findContactInContactList(contacts: any[], query: string) { const phoneNumbers = this.getNumbers(query); const searchableFields = this.getSearchableFields(); // eslint-disable-next-line prettier/prettier if (contacts.length === 2 && this.getClientCwConfig().mergeBrazilContacts && query.startsWith('+55')) { const contact = this.mergeBrazilianContacts(contacts); if (contact) { return contact; } } const phone = phoneNumbers.reduce( (savedNumber, number) => (number.length > savedNumber.length ? number : savedNumber), '', ); const contact_with9 = contacts.find((contact) => contact.phone_number === phone); if (contact_with9) { return contact_with9; } for (const contact of contacts) { for (const field of searchableFields) { if (contact[field] && phoneNumbers.includes(contact[field])) { return contact; } } } return null; } private getNumbers(query: string) { const numbers = []; numbers.push(query); if (query.startsWith('+55') && query.length === 14) { const withoutNine = query.slice(0, 5) + query.slice(6); numbers.push(withoutNine); } else if (query.startsWith('+55') && query.length === 13) { const withNine = query.slice(0, 5) + '9' + query.slice(5); numbers.push(withNine); } return numbers; } private getSearchableFields() { return ['phone_number']; } private getFilterPayload(query: string) { const filterPayload = []; const numbers = this.getNumbers(query); const fieldsToSearch = this.getSearchableFields(); fieldsToSearch.forEach((field, index1) => { numbers.forEach((number, index2) => { const queryOperator = fieldsToSearch.length - 1 === index1 && numbers.length - 1 === index2 ? null : 'OR'; filterPayload.push({ attribute_key: field, filter_operator: 'equal_to', values: [number.replace('+', '')], query_operator: queryOperator, }); }); }); return filterPayload; } private pendingCreateConv = new Map>(); public async createConversation(instance: InstanceDto, body: any): Promise { const remoteJid = body.key.remoteJid as string; this.logger.verbose("[createConversation] Iniciando para remoteJid=" + remoteJid); // 0) Se já está criando, reutiliza a promise if (this.pendingCreateConv.has(remoteJid)) { this.logger.verbose("[createConversation] Ja em criacao para " + remoteJid + ", retornando promise existente"); return this.pendingCreateConv.get(remoteJid)!; } let triedRecovery = false; const cacheKey = instance.instanceName + ":createConversation-" + remoteJid; const p = (async (): Promise => { try { this.logger.verbose("[createConversation] Chamando _createConversation pela primeira vez"); return await this._createConversation(instance, body); } catch (err) { this.logger.error("[createConversation] Erro na primeira tentativa: " + err); if (!triedRecovery) { triedRecovery = true; this.logger.warn("[createConversation] Tentando recuperacao: limpando cache e recriando conversa"); await this.cache.delete(cacheKey); this.logger.verbose("[createConversation] Cache deletado para chave=" + cacheKey); return await this._createConversation(instance, body); } this.logger.error("[createConversation] Ja tentei recuperacao, repassando erro"); throw err; } })(); this.pendingCreateConv.set(remoteJid, p); try { const convId = await p; this.logger.verbose("[createConversation] Concluido para " + remoteJid + ", convId=" + convId); return convId; } finally { this.pendingCreateConv.delete(remoteJid); this.logger.verbose("[createConversation] Removido pendingCreateConv para " + remoteJid); } } private async _createConversation(instance: InstanceDto, body: any): Promise { const remoteJid = body.key.remoteJid as string; const cacheKey = instance.instanceName + ":createConversation-" + remoteJid; this.logger.verbose("[_createConversation] Start para remoteJid=" + remoteJid); // 1) Cliente Chatwoot const client = await this.clientCw(instance); if (!client) { this.logger.error("[_createConversation] Client Chatwoot nao encontrado para " + instance.instanceName); throw new Error("Client not found for instance: " + instance.instanceName); } this.logger.verbose("[_createConversation] Client Chatwoot obtido"); // 2) Cache const hasCache = await this.cache.has(cacheKey); this.logger.verbose("[_createConversation] Cache check para key=" + cacheKey + ": " + hasCache); if (hasCache) { const cachedId = (await this.cache.get(cacheKey)) as number; this.logger.verbose("[_createConversation] Usando ID em cache=" + cachedId); return cachedId; } // 3) Inbox const filterInbox = await this.getInbox(instance); if (!filterInbox) { this.logger.error("[_createConversation] Inbox nao encontrada para " + instance.instanceName); throw new Error("Inbox not found for instance: " + instance.instanceName); } this.logger.verbose("[_createConversation] Inbox encontrada: id=" + filterInbox.id); // 4) Contato const isGroup = remoteJid.includes("@g.us"); const chatId = isGroup ? remoteJid : remoteJid.split("@")[0]; this.logger.verbose("[_createConversation] isGroup=" + isGroup + ", chatId=" + chatId); let contact = await this.findContact(instance, chatId); if (contact) { this.logger.verbose("[_createConversation] Contato encontrado: id=" + contact.id); } else { this.logger.verbose("[_createConversation] Contato nao existe, criando..."); const isOutgoing = body.key.fromMe; const senderName = !isOutgoing && body.pushName ? body.pushName : chatId; const name = isGroup ? (await this.waMonitor.waInstances[instance.instanceName] .client.groupMetadata(chatId)).subject + " (GROUP)" : senderName; const pictureUrl = (await this.waMonitor.waInstances[instance.instanceName].profilePicture(chatId)).profilePictureUrl; contact = await this.createContact( instance, chatId, filterInbox.id, isGroup, name, pictureUrl || null, isGroup ? remoteJid : undefined ); if (!contact) { this.logger.error("[_createConversation] Falha ao criar contato para " + chatId); throw new Error("Nao conseguiu criar contato para conversa"); } this.logger.verbose("[_createConversation] Contato criado: id=" + contact.id); } const contactId = (contact.id ?? contact.payload?.contact?.id) as number; // 5) Listar conversas existentes this.logger.verbose("[_createConversation] Chamando listConversations para contactId=" + contactId); const listResp: any = await client.contacts.listConversations({ accountId: this.provider.accountId, id: contactId, }); this.logger.verbose("[_createConversation] listConversations raw: " + JSON.stringify(listResp)); let conversations: any[] = []; if (Array.isArray(listResp)) conversations = listResp; else if (Array.isArray(listResp.payload)) conversations = listResp.payload; else if (Array.isArray(listResp.data?.payload)) conversations = listResp.data.payload; else if (Array.isArray(listResp.data)) conversations = listResp.data; this.logger.verbose("[_createConversation] Encontradas " + conversations.length + " conversas"); // 6) Filtrar conversa aberta ou pendente let conv = null; if (this.provider.reopenConversation) { this.logger.verbose("[_createConversation] reopenConversation=true, buscando inbox_id=" + filterInbox.id); conv = conversations.find(c => c.inbox_id === filterInbox.id); if (conv && this.provider.conversationPending && conv.status !== "pending") { this.logger.verbose("[_createConversation] Reabrindo conversa " + conv.id + " para status=pending"); await client.conversations.toggleStatus({ accountId: this.provider.accountId, conversationId: conv.id, data: { status: "pending" }, }); } } else { this.logger.verbose("[_createConversation] reopenConversation=false, buscando status!=resolved"); conv = conversations.find(c => c.status !== "resolved" && c.inbox_id === filterInbox.id); } if (conv) { this.logger.verbose("[_createConversation] Usando conversa existente id=" + conv.id); this.cache.set(cacheKey, conv.id, 5 * 60); return conv.id; } // 7) Criar nova conversa this.logger.verbose("[_createConversation] Nenhuma conversa encontrada, criando nova..."); const payload: any = { contact_id: contactId.toString(), inbox_id: filterInbox.id.toString(), ...(this.provider.conversationPending ? { status: "pending" } : {}), }; try { const newConv = await client.conversations.create({ accountId: this.provider.accountId, data: payload, }); const displayId = (newConv as any).display_id ?? newConv.id; if (!displayId) { this.logger.error("[_createConversation] create retornou sem DisplayID"); throw new Error("Falha ao criar nova conversa: resposta sem DisplayID"); } this.logger.verbose("[_createConversation] Nova conversa criada DisplayId=" + displayId); this.cache.set(cacheKey, displayId, 5 * 60); return displayId; } catch (err: any) { this.logger.error("[_createConversation] Erro ao criar conversa: " + err); this.logger.warn("[_createConversation] Tentando recuperar conversa via listConversations novamente"); const retryList: any = await client.contacts.listConversations({ accountId: this.provider.accountId, id: contactId, }); this.logger.verbose("[_createConversation] retry listConversations raw: " + JSON.stringify(retryList)); let retryConvs: any[] = []; if (Array.isArray(retryList)) retryConvs = retryList; else if (Array.isArray(retryList.payload)) retryConvs = retryList.payload; else if (Array.isArray(retryList.data?.payload)) retryConvs = retryList.data.payload; else if (Array.isArray(retryList.data)) retryConvs = retryList.data; this.logger.verbose("[_createConversation] retry encontrou " + retryConvs.length + " conversas"); const recovered = retryConvs.find(c => c.inbox_id === filterInbox.id); if (recovered) { this.logger.verbose("[_createConversation] Recuperou conversa existente id=" + recovered.id); this.cache.set(cacheKey, recovered.id, 5 * 60); return recovered.id; } this.logger.error("[_createConversation] Nao recuperou conversa, repassando erro"); throw err; } } public async getInbox(instance: InstanceDto): Promise { const cacheKey = `${instance.instanceName}:getInbox`; if (await this.cache.has(cacheKey)) { return (await this.cache.get(cacheKey)) as inbox; } const client = await this.clientCw(instance); if (!client) { this.logger.warn('client not found'); return null; } const inbox = (await client.inboxes.list({ accountId: this.provider.accountId, })) as any; if (!inbox) { this.logger.warn('inbox not found'); return null; } const findByName = inbox.payload.find((inbox) => inbox.name === this.getClientCwConfig().nameInbox); if (!findByName) { this.logger.warn('inbox not found'); return null; } this.cache.set(cacheKey, findByName); return findByName; } public async createMessage( instance: InstanceDto, conversationId: number, content: string, messageType: 'incoming' | 'outgoing' | undefined, privateMessage?: boolean, attachments?: { content: unknown; encoding: string; filename: string; }[], messageBody?: any, sourceId?: string, quotedMsg?: MessageModel, ) { if (sourceId && this.isImportHistoryAvailable()) { const messageAlreadySaved = await chatwootImport.getExistingSourceIds([sourceId]); if (messageAlreadySaved && messageAlreadySaved.size > 0) { this.logger.warn('Message already saved on chatwoot'); return null; } } const client = await this.clientCw(instance); if (!client) { this.logger.warn('client not found'); return null; } const replyToIds = await this.getReplyToIds(messageBody, instance); const sourceReplyId = quotedMsg?.chatwootMessageId || null; const message = await client.messages.create({ accountId: this.provider.accountId, conversationId: conversationId, data: { content: content, message_type: messageType, attachments: attachments, private: privateMessage || false, source_id: sourceId, content_attributes: { ...replyToIds, }, source_reply_id: sourceReplyId ? sourceReplyId.toString() : null, }, }); if (!message) { this.logger.warn('message not found'); return null; } return message; } public async getOpenConversationByContact( instance: InstanceDto, inbox: inbox, contact: generic_id & contact, ): Promise { const client = await this.clientCw(instance); if (!client) { this.logger.warn('client not found'); return null; } const conversations = (await client.contacts.listConversations({ accountId: this.provider.accountId, id: contact.id, })) as any; return ( conversations.payload.find( (conversation) => conversation.inbox_id === inbox.id && conversation.status === 'open', ) || undefined ); } public async createBotMessage( instance: InstanceDto, content: string, messageType: 'incoming' | 'outgoing' | undefined, attachments?: { content: unknown; encoding: string; filename: string; }[], ) { const client = await this.clientCw(instance); if (!client) { this.logger.warn('client not found'); return null; } const contact = await this.findContact(instance, '123456'); if (!contact) { this.logger.warn('contact not found'); return null; } const filterInbox = await this.getInbox(instance); if (!filterInbox) { this.logger.warn('inbox not found'); return null; } const conversation = await this.getOpenConversationByContact(instance, filterInbox, contact); if (!conversation) { this.logger.warn('conversation not found'); return; } const message = await client.messages.create({ accountId: this.provider.accountId, conversationId: conversation.id, data: { content: content, message_type: messageType, attachments: attachments, }, }); if (!message) { this.logger.warn('message not found'); return null; } return message; } private async sendData( conversationId: number, fileStream: Readable, fileName: string, messageType: 'incoming' | 'outgoing' | undefined, content?: string, instance?: InstanceDto, messageBody?: any, sourceId?: string, quotedMsg?: MessageModel, ) { if (sourceId && this.isImportHistoryAvailable()) { const messageAlreadySaved = await chatwootImport.getExistingSourceIds([sourceId]); if (messageAlreadySaved) { if (messageAlreadySaved.size > 0) { this.logger.warn('Message already saved on chatwoot'); return null; } } } const data = new FormData(); if (content) { data.append('content', content); } data.append('message_type', messageType); data.append('attachments[]', fileStream, { filename: fileName }); const sourceReplyId = quotedMsg?.chatwootMessageId || null; if (messageBody && instance) { const replyToIds = await this.getReplyToIds(messageBody, instance); if (replyToIds.in_reply_to || replyToIds.in_reply_to_external_id) { const content = JSON.stringify({ ...replyToIds, }); data.append('content_attributes', content); } } if (sourceReplyId) { data.append('source_reply_id', sourceReplyId.toString()); } if (sourceId) { data.append('source_id', sourceId); } const config = { method: 'post', maxBodyLength: Infinity, url: `${this.provider.url}/api/v1/accounts/${this.provider.accountId}/conversations/${conversationId}/messages`, headers: { api_access_token: this.provider.token, ...data.getHeaders(), }, data: data, }; try { const { data } = await axios.request(config); return data; } catch (error) { this.logger.error(error); } } public async createBotQr( instance: InstanceDto, content: string, messageType: 'incoming' | 'outgoing' | undefined, fileStream?: Readable, fileName?: string, ) { const client = await this.clientCw(instance); if (!client) { this.logger.warn('client not found'); return null; } if (!this.configService.get('CHATWOOT').BOT_CONTACT) { this.logger.log('Chatwoot bot contact is disabled'); return true; } const contact = await this.findContact(instance, '123456'); if (!contact) { this.logger.warn('contact not found'); return null; } const filterInbox = await this.getInbox(instance); if (!filterInbox) { this.logger.warn('inbox not found'); return null; } const conversation = await this.getOpenConversationByContact(instance, filterInbox, contact); if (!conversation) { this.logger.warn('conversation not found'); return; } const data = new FormData(); if (content) { data.append('content', content); } data.append('message_type', messageType); if (fileStream && fileName) { data.append('attachments[]', fileStream, { filename: fileName }); } const config = { method: 'post', maxBodyLength: Infinity, url: `${this.provider.url}/api/v1/accounts/${this.provider.accountId}/conversations/${conversation.id}/messages`, headers: { api_access_token: this.provider.token, ...data.getHeaders(), }, data: data, }; try { const { data } = await axios.request(config); return data; } catch (error) { this.logger.error(error); } } 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; if (!mimeType) { const parts = media.split('/'); fileName = decodeURIComponent(parts[parts.length - 1]); const response = await axios.get(media, { responseType: 'arraybuffer', }); mimeType = response.headers['content-type']; } let type = 'document'; switch (mimeType.split('/')[0]) { case 'image': type = 'image'; break; case 'video': type = 'video'; break; case 'audio': type = 'audio'; break; default: type = 'document'; break; } if (type === 'audio') { const data: SendAudioDto = { number: number, audio: media, delay: 1200, quoted: options?.quoted, }; sendTelemetry('/message/sendWhatsAppAudio'); const messageSent = await waInstance?.audioWhatsapp(data, true); return messageSent; } if (type === 'image' && parsedMedia && parsedMedia?.ext === '.gif') { type = 'document'; } const data: SendMediaDto = { number: number, mediatype: type as any, fileName: fileName, media: media, delay: 1200, quoted: options?.quoted, }; sendTelemetry('/message/sendMedia'); if (caption) { data.caption = caption; } const messageSent = await waInstance?.mediaMessage(data, null, true); return messageSent; } catch (error) { this.logger.error(error); } } public async onSendMessageError(instance: InstanceDto, conversation: number, error?: any) { this.logger.verbose(`onSendMessageError ${JSON.stringify(error)}`); const client = await this.clientCw(instance); if (!client) { return; } if (error && error?.status === 400 && error?.message[0]?.exists === false) { client.messages.create({ accountId: this.provider.accountId, conversationId: conversation, data: { content: `${i18next.t('cw.message.numbernotinwhatsapp')}`, message_type: 'outgoing', private: true, }, }); return; } client.messages.create({ accountId: this.provider.accountId, conversationId: conversation, data: { content: i18next.t('cw.message.notsent', { error: error ? `_${error.toString()}_` : '', }), message_type: 'outgoing', private: true, }, }); } public async receiveWebhook(instance: InstanceDto, body: any) { try { await new Promise((resolve) => setTimeout(resolve, 500)); const client = await this.clientCw(instance); if (!client) { this.logger.warn('client not found'); return null; } if ( this.provider.reopenConversation === false && body.event === 'conversation_status_changed' && body.status === 'resolved' && body.meta?.sender?.identifier ) { const keyToDelete = `${instance.instanceName}:createConversation-${body.meta.sender.identifier}`; this.cache.delete(keyToDelete); } if ( !body?.conversation || body.private || (body.event === 'message_updated' && !body.content_attributes?.deleted) ) { return { message: 'bot' }; } const chatId = body.conversation.meta.sender?.identifier || body.conversation.meta.sender?.phone_number.replace('+', ''); // Chatwoot to Whatsapp const messageReceived = body.content ? body.content .replaceAll(/(?('CHATWOOT').BOT_CONTACT; if (chatId === '123456' && body.message_type === 'outgoing') { const command = messageReceived.replace('/', ''); if (cwBotContact && (command.includes('init') || command.includes('iniciar'))) { const state = waInstance?.connectionStatus?.state; if (state !== 'open') { const number = command.split(':')[1]; await waInstance.connectToWhatsapp(number); } else { await this.createBotMessage( instance, i18next.t('cw.inbox.alreadyConnected', { inboxName: body.inbox.name, }), 'incoming', ); } } if (command === 'clearcache') { waInstance.clearCacheChatwoot(); await this.createBotMessage( instance, i18next.t('cw.inbox.clearCache', { inboxName: body.inbox.name, }), 'incoming', ); } if (command === 'status') { const state = waInstance?.connectionStatus?.state; if (!state) { await this.createBotMessage( instance, i18next.t('cw.inbox.notFound', { inboxName: body.inbox.name, }), 'incoming', ); } if (state) { await this.createBotMessage( instance, i18next.t('cw.inbox.status', { inboxName: body.inbox.name, state: state, }), 'incoming', ); } } if (cwBotContact && (command === 'disconnect' || command === 'desconectar')) { const msgLogout = i18next.t('cw.inbox.disconnect', { inboxName: body.inbox.name, }); await this.createBotMessage(instance, msgLogout, 'incoming'); await waInstance?.client?.logout('Log out instance: ' + instance.instanceName); await waInstance?.client?.ws?.close(); } } if (body.message_type === 'outgoing' && body?.conversation?.messages?.length && chatId !== '123456') { if (body?.conversation?.messages[0]?.source_id?.substring(0, 5) === 'WAID:') { return { message: 'bot' }; } if (!waInstance && body.conversation?.id) { this.onSendMessageError(instance, body.conversation?.id, 'Instance not found'); return { message: 'bot' }; } let formatText: string; if (senderName === null || senderName === undefined) { formatText = messageReceived; } else { const formattedDelimiter = this.provider.signDelimiter ? this.provider.signDelimiter.replaceAll('\\n', '\n') : '\n'; const textToConcat = this.provider.signMsg ? [`*${senderName}:*`] : []; textToConcat.push(messageReceived); formatText = textToConcat.join(formattedDelimiter); } for (const message of body.conversation.messages) { if (message.attachments && message.attachments.length > 0) { for (const attachment of message.attachments) { if (!messageReceived) { formatText = null; } 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, ); } } else { const data: SendTextDto = { number: chatId, text: formatText, delay: 1200, quoted: await this.getQuotedMessage(body, instance), }; sendTelemetry('/message/sendText'); let messageSent: any; try { messageSent = await waInstance?.textMessage(data, true); if (!messageSent) { throw new Error('Message not sent'); } if (Long.isLong(messageSent?.messageTimestamp)) { messageSent.messageTimestamp = messageSent.messageTimestamp?.toNumber(); } await this.updateChatwootMessageId( { ...messageSent, instanceId: instance.instanceId, }, { messageId: body.id, inboxId: body.inbox?.id, conversationId: body.conversation?.id, contactInboxSourceId: body.conversation?.contact_inbox?.source_id, }, instance, ); } catch (error) { if (!messageSent && body.conversation?.id) { this.onSendMessageError(instance, body.conversation?.id, error); } throw error; } } } const chatwootRead = this.configService.get('CHATWOOT').MESSAGE_READ; if (chatwootRead) { const lastMessage = await this.prismaRepository.message.findFirst({ where: { key: { path: ['fromMe'], equals: false, }, instanceId: instance.instanceId, }, }); if (lastMessage && !lastMessage.chatwootIsRead) { const key = lastMessage.key as { id: string; fromMe: boolean; remoteJid: string; participant?: string; }; waInstance?.markMessageAsRead({ readMessages: [ { id: key.id, fromMe: key.fromMe, remoteJid: key.remoteJid, }, ], }); const updateMessage = { chatwootMessageId: lastMessage.chatwootMessageId, chatwootConversationId: lastMessage.chatwootConversationId, chatwootInboxId: lastMessage.chatwootInboxId, chatwootContactInboxSourceId: lastMessage.chatwootContactInboxSourceId, chatwootIsRead: true, }; await this.prismaRepository.message.updateMany({ where: { instanceId: instance.instanceId, key: { path: ['id'], equals: key.id, }, }, data: updateMessage, }); } } } if (body.message_type === 'template' && body.event === 'message_created') { const data: SendTextDto = { number: chatId, text: body.content.replace(/\\\r\n|\\\n|\n/g, '\n'), delay: 1200, }; sendTelemetry('/message/sendText'); await waInstance?.textMessage(data); } return { message: 'bot' }; } catch (error) { this.logger.error(error); return { message: 'bot' }; } } private async updateChatwootMessageId( message: MessageModel, chatwootMessageIds: ChatwootMessage, instance: InstanceDto, ) { const key = message.key as { id: string; fromMe: boolean; remoteJid: string; participant?: string; }; if (!chatwootMessageIds.messageId || !key?.id) { return; } await this.prismaRepository.message.updateMany({ where: { key: { path: ['id'], equals: key.id, }, instanceId: instance.instanceId, }, data: { chatwootMessageId: chatwootMessageIds.messageId, chatwootConversationId: chatwootMessageIds.conversationId, chatwootInboxId: chatwootMessageIds.inboxId, chatwootContactInboxSourceId: chatwootMessageIds.contactInboxSourceId, chatwootIsRead: chatwootMessageIds.isRead, }, }); if (this.isImportHistoryAvailable()) { chatwootImport.updateMessageSourceID(chatwootMessageIds.messageId, key.id); } } private async getMessageByKeyId(instance: InstanceDto, keyId: string): Promise { const messages = await this.prismaRepository.message.findFirst({ where: { key: { path: ['id'], equals: keyId, }, instanceId: instance.instanceId, }, }); return messages || null; } private async getReplyToIds( msg: any, instance: InstanceDto, ): Promise<{ in_reply_to: string; in_reply_to_external_id: string }> { let inReplyTo = null; let inReplyToExternalId = null; if (msg) { inReplyToExternalId = msg.message?.extendedTextMessage?.contextInfo?.stanzaId ?? msg.contextInfo?.stanzaId; if (inReplyToExternalId) { const message = await this.getMessageByKeyId(instance, inReplyToExternalId); if (message?.chatwootMessageId) { inReplyTo = message.chatwootMessageId; } } } return { in_reply_to: inReplyTo, in_reply_to_external_id: inReplyToExternalId, }; } private async getQuotedMessage(msg: any, instance: InstanceDto): Promise { if (msg?.content_attributes?.in_reply_to) { const message = await this.prismaRepository.message.findFirst({ where: { chatwootMessageId: msg?.content_attributes?.in_reply_to, instanceId: instance.instanceId, }, }); const key = message?.key as { id: string; fromMe: boolean; remoteJid: string; participant?: string; }; if (message && key?.id) { return { key: message.key as proto.IMessageKey, message: message.message as proto.IMessage, }; } } return null; } private isMediaMessage(message: any) { const media = [ 'imageMessage', 'documentMessage', 'documentWithCaptionMessage', 'audioMessage', 'videoMessage', 'stickerMessage', 'viewOnceMessageV2', ]; const messageKeys = Object.keys(message); const result = messageKeys.some((key) => media.includes(key)); return result; } private getAdsMessage(msg: any) { interface AdsMessage { title: string; body: string; thumbnailUrl: string; sourceUrl: string; } const adsMessage: AdsMessage | undefined = { title: msg.extendedTextMessage?.contextInfo?.externalAdReply?.title || msg.contextInfo?.externalAdReply?.title, body: msg.extendedTextMessage?.contextInfo?.externalAdReply?.body || msg.contextInfo?.externalAdReply?.body, thumbnailUrl: msg.extendedTextMessage?.contextInfo?.externalAdReply?.thumbnailUrl || msg.contextInfo?.externalAdReply?.thumbnailUrl, sourceUrl: msg.extendedTextMessage?.contextInfo?.externalAdReply?.sourceUrl || msg.contextInfo?.externalAdReply?.sourceUrl, }; return adsMessage; } private getReactionMessage(msg: any) { interface ReactionMessage { key: { id: string; fromMe: boolean; remoteJid: string; participant?: string; }; text: string; } const reactionMessage: ReactionMessage | undefined = msg?.reactionMessage; return reactionMessage; } private getTypeMessage(msg: any) { const types = { conversation: msg.conversation, imageMessage: msg.imageMessage?.caption, videoMessage: msg.videoMessage?.caption, extendedTextMessage: msg.extendedTextMessage?.text, messageContextInfo: msg.messageContextInfo?.stanzaId, stickerMessage: undefined, documentMessage: msg.documentMessage?.caption, documentWithCaptionMessage: msg.documentWithCaptionMessage?.message?.documentMessage?.caption, audioMessage: msg.audioMessage?.caption, contactMessage: msg.contactMessage?.vcard, contactsArrayMessage: msg.contactsArrayMessage, locationMessage: msg.locationMessage, liveLocationMessage: msg.liveLocationMessage, listMessage: msg.listMessage, listResponseMessage: msg.listResponseMessage, viewOnceMessageV2: msg?.message?.viewOnceMessageV2?.message?.imageMessage?.url || msg?.message?.viewOnceMessageV2?.message?.videoMessage?.url || msg?.message?.viewOnceMessageV2?.message?.audioMessage?.url, }; return types; } private getMessageContent(types: any) { const typeKey = Object.keys(types).find((key) => types[key] !== undefined); let result = typeKey ? types[typeKey] : undefined; // Remove externalAdReplyBody| in Chatwoot (Already Have) if (result && typeof result === 'string' && result.includes('externalAdReplyBody|')) { result = result.split('externalAdReplyBody|').filter(Boolean).join(''); } if (typeKey === 'locationMessage' || typeKey === 'liveLocationMessage') { const latitude = result.degreesLatitude; const longitude = result.degreesLongitude; const locationName = result?.name; const locationAddress = result?.address; const formattedLocation = `*${i18next.t('cw.locationMessage.location')}:*\n\n` + `_${i18next.t('cw.locationMessage.latitude')}:_ ${latitude} \n` + `_${i18next.t('cw.locationMessage.longitude')}:_ ${longitude} \n` + (locationName ? `_${i18next.t('cw.locationMessage.locationName')}:_ ${locationName}\n` : '') + (locationAddress ? `_${i18next.t('cw.locationMessage.locationAddress')}:_ ${locationAddress} \n` : '') + `_${i18next.t('cw.locationMessage.locationUrl')}:_ ` + `https://www.google.com/maps/search/?api=1&query=${latitude},${longitude}`; return formattedLocation; } if (typeKey === 'contactMessage') { const vCardData = result.split('\n'); const contactInfo = {}; vCardData.forEach((line) => { const [key, value] = line.split(':'); if (key && value) { contactInfo[key] = value; } }); let formattedContact = `*${i18next.t('cw.contactMessage.contact')}:*\n\n` + `_${i18next.t('cw.contactMessage.name')}:_ ${contactInfo['FN']}`; let numberCount = 1; Object.keys(contactInfo).forEach((key) => { if (key.startsWith('item') && key.includes('TEL')) { const phoneNumber = contactInfo[key]; formattedContact += `\n_${i18next.t('cw.contactMessage.number')} (${numberCount}):_ ${phoneNumber}`; numberCount++; } else if (key.includes('TEL')) { const phoneNumber = contactInfo[key]; formattedContact += `\n_${i18next.t('cw.contactMessage.number')} (${numberCount}):_ ${phoneNumber}`; numberCount++; } }); return formattedContact; } if (typeKey === 'contactsArrayMessage') { const formattedContacts = result.contacts.map((contact) => { const vCardData = contact.vcard.split('\n'); const contactInfo = {}; vCardData.forEach((line) => { const [key, value] = line.split(':'); if (key && value) { contactInfo[key] = value; } }); let formattedContact = `*${i18next.t('cw.contactMessage.contact')}:*\n\n_${i18next.t( 'cw.contactMessage.name', )}:_ ${contact.displayName}`; let numberCount = 1; Object.keys(contactInfo).forEach((key) => { if (key.startsWith('item') && key.includes('TEL')) { const phoneNumber = contactInfo[key]; formattedContact += `\n_${i18next.t('cw.contactMessage.number')} (${numberCount}):_ ${phoneNumber}`; numberCount++; } else if (key.includes('TEL')) { const phoneNumber = contactInfo[key]; formattedContact += `\n_${i18next.t('cw.contactMessage.number')} (${numberCount}):_ ${phoneNumber}`; numberCount++; } }); return formattedContact; }); const formattedContactsArray = formattedContacts.join('\n\n'); return formattedContactsArray; } if (typeKey === 'listMessage') { const listTitle = result?.title || 'Unknown'; const listDescription = result?.description || 'Unknown'; const listFooter = result?.footerText || 'Unknown'; let formattedList = '*List Menu:*\n\n' + '_Title_: ' + listTitle + '\n' + '_Description_: ' + listDescription + '\n' + '_Footer_: ' + listFooter; if (result.sections && result.sections.length > 0) { result.sections.forEach((section, sectionIndex) => { formattedList += '\n\n*Section ' + (sectionIndex + 1) + ':* ' + section.title || 'Unknown\n'; if (section.rows && section.rows.length > 0) { section.rows.forEach((row, rowIndex) => { formattedList += '\n*Line ' + (rowIndex + 1) + ':*\n'; formattedList += '_▪️ Title:_ ' + (row.title || 'Unknown') + '\n'; formattedList += '_▪️ Description:_ ' + (row.description || 'Unknown') + '\n'; formattedList += '_▪️ ID:_ ' + (row.rowId || 'Unknown') + '\n'; }); } else { formattedList += '\nNo lines found in this section.\n'; } }); } else { formattedList += '\nNo sections found.\n'; } return formattedList; } if (typeKey === 'listResponseMessage') { const responseTitle = result?.title || 'Unknown'; const responseDescription = result?.description || 'Unknown'; const responseRowId = result?.singleSelectReply?.selectedRowId || 'Unknown'; const formattedResponseList = '*List Response:*\n\n' + '_Title_: ' + responseTitle + '\n' + '_Description_: ' + responseDescription + '\n' + '_ID_: ' + responseRowId; return formattedResponseList; } return result; } public getConversationMessage(msg: any) { const types = this.getTypeMessage(msg); const messageContent = this.getMessageContent(types); return messageContent; } public async eventWhatsapp(event: string, instance: InstanceDto, body: any) { try { const waInstance = this.waMonitor.waInstances[instance.instanceName]; if (!waInstance) { this.logger.warn('wa instance not found'); return null; } const client = await this.clientCw(instance); if (!client) { this.logger.warn('client not found'); return null; } if (this.provider?.ignoreJids && this.provider?.ignoreJids.length > 0) { const ignoreJids: any = this.provider?.ignoreJids; let ignoreGroups = false; let ignoreContacts = false; if (ignoreJids.includes('@g.us')) { ignoreGroups = true; } if (ignoreJids.includes('@s.whatsapp.net')) { ignoreContacts = true; } if (ignoreGroups && body?.key?.remoteJid.endsWith('@g.us')) { this.logger.warn('Ignoring message from group: ' + body?.key?.remoteJid); return; } if (ignoreContacts && body?.key?.remoteJid.endsWith('@s.whatsapp.net')) { this.logger.warn('Ignoring message from contact: ' + body?.key?.remoteJid); return; } if (ignoreJids.includes(body?.key?.remoteJid)) { this.logger.warn('Ignoring message from jid: ' + body?.key?.remoteJid); return; } } if (event === 'messages.upsert' || event === 'send.message') { if (body.key.remoteJid === 'status@broadcast') { return; } if (body.message?.ephemeralMessage?.message) { body.message = { ...body.message?.ephemeralMessage?.message, }; } const originalMessage = await this.getConversationMessage(body.message); const bodyMessage = originalMessage ? originalMessage .replaceAll(/\*((?!\s)([^\n*]+?)(? { }; fileStream.push(fileData); fileStream.push(null); if (body.key.remoteJid.includes('@g.us')) { const participantName = body.pushName; const rawPhoneNumber = body.key.participant.split('@')[0]; const phoneMatch = rawPhoneNumber.match(/^(\d{2})(\d{2})(\d{4})(\d{4})$/); let formattedPhoneNumber: string; if (phoneMatch) { formattedPhoneNumber = `+${phoneMatch[1]} (${phoneMatch[2]}) ${phoneMatch[3]}-${phoneMatch[4]}`; } else { formattedPhoneNumber = `+${rawPhoneNumber}`; } let content: string; if (!body.key.fromMe) { content = `**${formattedPhoneNumber} - ${participantName}:**\n\n${bodyMessage}`; } else { content = `${bodyMessage}`; } const send = await this.sendData( getConversation, fileStream, nameFile, messageType, content, instance, body, 'WAID:' + body.key.id, quotedMsg, ); if (!send) { this.logger.warn('message not sent'); return; } return send; } else { const send = await this.sendData( getConversation, fileStream, nameFile, messageType, bodyMessage, instance, body, 'WAID:' + body.key.id, quotedMsg, ); if (!send) { this.logger.warn('message not sent'); return; } return send; } } if (reactionMessage) { if (reactionMessage.text) { const send = await this.createMessage( instance, getConversation, reactionMessage.text, messageType, false, [], { message: { extendedTextMessage: { contextInfo: { stanzaId: reactionMessage.key.id } } }, }, 'WAID:' + body.key.id, quotedMsg, ); if (!send) { this.logger.warn('message not sent'); return; } } return; } const isAdsMessage = (adsMessage && adsMessage.title) || adsMessage.body || adsMessage.thumbnailUrl; if (isAdsMessage) { const imgBuffer = await axios.get(adsMessage.thumbnailUrl, { responseType: 'arraybuffer' }); const extension = mimeTypes.extension(imgBuffer.headers['content-type']); const mimeType = extension && mimeTypes.lookup(extension); if (!mimeType) { this.logger.warn('mimetype of Ads message not found'); return; } const random = Math.random().toString(36).substring(7); const nameFile = `${random}.${mimeTypes.extension(mimeType)}`; 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); 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 ''; return str.length > len ? str.substring(0, len) + '...' : str; }; const title = truncStr(adsMessage.title, 40); const description = truncStr(adsMessage?.body, 75); const send = await this.sendData( getConversation, fileStream, nameFile, messageType, `${bodyMessage}\n\n\n**${title}**\n${description}\n${adsMessage.sourceUrl}`, instance, body, 'WAID:' + body.key.id, ); if (!send) { this.logger.warn('message not sent'); return; } return send; } if (body.key.remoteJid.includes('@g.us')) { // Extrai de forma segura o JID do participante const participantJid = body.key.participant; // Se não veio participant, envia mensagem crua if (!participantJid) { const rawContent = bodyMessage; const sent = await this.createMessage( instance, getConversation, rawContent, messageType, false, [], body, 'WAID:' + body.key.id, quotedMsg, ); if (!sent) this.logger.warn('message not sent'); return sent; } // Formata o telefone const rawPhone = participantJid.split('@')[0]; const match = rawPhone.match(/^(\d{2})(\d{2})(\d{4})(\d{4})$/); const formattedPhone = match ? `+${match[1]} (${match[2]}) ${match[3]}-${match[4]}` : `+${rawPhone}`; // Define prefixo com número e nome (ou só número, se pushName vazio) const name = body.pushName?.trim(); const prefix = name ? `**${formattedPhone} – ${name}:**\n\n` : `**${formattedPhone}:**\n\n`; // Monta o conteúdo, omitindo prefixo em mensagens enviadas por mim const content = body.key.fromMe ? bodyMessage : `${prefix}${bodyMessage}`; // Envia a mensagem formatada const sent = await this.createMessage( instance, getConversation, content, messageType, false, [], body, 'WAID:' + body.key.id, quotedMsg, ); if (!sent) this.logger.warn('message not sent'); return sent; } else { const send = await this.createMessage( instance, getConversation, bodyMessage, messageType, false, [], body, 'WAID:' + body.key.id, quotedMsg, ); if (!send) { this.logger.warn('message not sent'); return; } return send; } } if (event === Events.MESSAGES_DELETE) { const chatwootDelete = this.configService.get('CHATWOOT').MESSAGE_DELETE; if (chatwootDelete === true) { if (!body?.key?.id) { this.logger.warn('message id not found'); return; } const message = await this.getMessageByKeyId(instance, body.key.id); if (message?.chatwootMessageId && message?.chatwootConversationId) { await this.prismaRepository.message.deleteMany({ where: { key: { path: ['id'], equals: body.key.id, }, instanceId: instance.instanceId, }, }); return await client.messages.delete({ accountId: this.provider.accountId, conversationId: message.chatwootConversationId, messageId: message.chatwootMessageId, }); } } } if (event === 'messages.edit') { const editedText = `${body?.editedMessage?.conversation || body?.editedMessage?.extendedTextMessage?.text }\n\n_\`${i18next.t('cw.message.edited')}.\`_`; const message = await this.getMessageByKeyId(instance, body?.key?.id); const key = message.key as { id: string; fromMe: boolean; remoteJid: string; participant?: string; }; const messageType = key?.fromMe ? 'outgoing' : 'incoming'; if (message && message.chatwootConversationId) { const send = await this.createMessage( instance, message.chatwootConversationId, editedText, messageType, false, [], { message: { extendedTextMessage: { contextInfo: { stanzaId: key.id } } }, }, 'WAID:' + body.key.id, null, ); if (!send) { this.logger.warn('edited message not sent'); return; } } return; } if (event === 'messages.read') { if (!body?.key?.id || !body?.key?.remoteJid) { this.logger.warn('message id not found'); return; } const message = await this.getMessageByKeyId(instance, body.key.id); const conversationId = message?.chatwootConversationId; const contactInboxSourceId = message?.chatwootContactInboxSourceId; if (conversationId) { let sourceId = contactInboxSourceId; const inbox = (await this.getInbox(instance)) as inbox & { inbox_identifier?: string; }; if (!sourceId && inbox) { const conversation = (await client.conversations.get({ accountId: this.provider.accountId, conversationId: conversationId, })) as conversation_show & { last_non_activity_message: { conversation: { contact_inbox: contact_inboxes } }; }; sourceId = conversation.last_non_activity_message?.conversation?.contact_inbox?.source_id; } if (sourceId && inbox?.inbox_identifier) { const url = `/public/api/v1/inboxes/${inbox.inbox_identifier}/contacts/${sourceId}` + `/conversations/${conversationId}/update_last_seen`; chatwootRequest(this.getClientCwConfig(), { method: 'POST', url: url, }); } } return; } if (event === 'status.instance') { const data = body; const inbox = await this.getInbox(instance); if (!inbox) { this.logger.warn('inbox not found'); return; } const msgStatus = i18next.t('cw.inbox.status', { inboxName: inbox.name, state: data.status, }); await this.createBotMessage(instance, msgStatus, 'incoming'); } if (event === 'connection.update') { if (body.status === 'open') { // if we have qrcode count then we understand that a new connection was established if (this.waMonitor.waInstances[instance.instanceName].qrCode.count > 0) { const msgConnection = i18next.t('cw.inbox.connected'); await this.createBotMessage(instance, msgConnection, 'incoming'); this.waMonitor.waInstances[instance.instanceName].qrCode.count = 0; chatwootImport.clearAll(instance); } } } if (event === 'qrcode.updated') { if (body.statusCode === 500) { const erroQRcode = `🚨 ${i18next.t('qrlimitreached')}`; return await this.createBotMessage(instance, erroQRcode, 'incoming'); } else { const fileData = Buffer.from(body?.qrcode.base64.replace('data:image/png;base64,', ''), 'base64'); const fileStream = new Readable(); fileStream._read = () => { }; fileStream.push(fileData); fileStream.push(null); await this.createBotQr( instance, i18next.t('qrgeneratedsuccesfully'), 'incoming', fileStream, `${instance.instanceName}.png`, ); let msgQrCode = `⚡️${i18next.t('qrgeneratedsuccesfully')}\n\n${i18next.t('scanqr')}`; if (body?.qrcode?.pairingCode) { msgQrCode = msgQrCode + `\n\n*Pairing Code:* ${body.qrcode.pairingCode.substring(0, 4)}-${body.qrcode.pairingCode.substring( 4, 8, )}`; } await this.createBotMessage(instance, msgQrCode, 'incoming'); } } } catch (error) { this.logger.error(error); } } public getNumberFromRemoteJid(remoteJid: string) { return remoteJid.replace(/:\d+/, '').split('@')[0]; } public startImportHistoryMessages(instance: InstanceDto) { if (!this.isImportHistoryAvailable()) { return; } this.createBotMessage(instance, i18next.t('cw.import.startImport'), 'incoming'); } public isImportHistoryAvailable() { const uri = this.configService.get('CHATWOOT').IMPORT.DATABASE.CONNECTION.URI; return uri && uri !== 'postgres://user:password@hostname:port/dbname'; } public addHistoryMessages(instance: InstanceDto, messagesRaw: MessageModel[]) { if (!this.isImportHistoryAvailable()) { return; } chatwootImport.addHistoryMessages(instance, messagesRaw); } public addHistoryContacts(instance: InstanceDto, contactsRaw: ContactModel[]) { if (!this.isImportHistoryAvailable()) { return; } return chatwootImport.addHistoryContacts(instance, contactsRaw); } public async importHistoryMessages(instance: InstanceDto) { if (!this.isImportHistoryAvailable()) { return; } this.createBotMessage(instance, i18next.t('cw.import.importingMessages'), 'incoming'); const totalMessagesImported = await chatwootImport.importHistoryMessages( instance, this, await this.getInbox(instance), this.provider, ); this.updateContactAvatarInRecentConversations(instance); const msg = Number.isInteger(totalMessagesImported) ? i18next.t('cw.import.messagesImported', { totalMessagesImported }) : i18next.t('cw.import.messagesException'); this.createBotMessage(instance, msg, 'incoming'); return totalMessagesImported; } public async updateContactAvatarInRecentConversations(instance: InstanceDto, limitContacts = 100) { try { if (!this.isImportHistoryAvailable()) { return; } const client = await this.clientCw(instance); if (!client) { this.logger.warn('client not found'); return null; } const inbox = await this.getInbox(instance); if (!inbox) { this.logger.warn('inbox not found'); return null; } const recentContacts = await chatwootImport.getContactsOrderByRecentConversations( inbox, this.provider, limitContacts, ); const contactIdentifiers = recentContacts .map((contact) => contact.identifier) .filter((identifier) => identifier !== null); const contactsWithProfilePicture = ( await this.prismaRepository.contact.findMany({ where: { instanceId: instance.instanceId, id: { in: contactIdentifiers, }, profilePicUrl: { not: null, }, }, }) ).reduce((acc: Map, contact: ContactModel) => acc.set(contact.id, contact), new Map()); for (const c of recentContacts) { const pic = contactsWithProfilePicture.get(c.identifier); if (!pic) continue; try { await client.contacts.update({ accountId: this.provider.accountId, id: c.id, data: { avatar_url: pic.profilePictureUrl || null, }, }); this.logger.verbose(`Avatar atualizado para o contato ${c.id}`); } catch (err) { this.logger.error(`Falha ao atualizar avatar do contato ${c.id}: ${err}`); } } } catch (error) { this.logger.error(`Error on update avatar in recent conversations: ${error.toString()}`); } } public async syncLostMessages( instance: InstanceDto, chatwootConfig: ChatwootDto, prepareMessage: (message: any) => any, ) { try { if (!this.isImportHistoryAvailable()) { return; } if (!this.configService.get('DATABASE').SAVE_DATA.MESSAGE_UPDATE) { return; } const inbox = await this.getInbox(instance); const sqlMessages = `select * from messages m where account_id = ${chatwootConfig.accountId} and inbox_id = ${inbox.id} and created_at >= now() - interval '6h' order by created_at desc`; const messagesData = (await this.pgClient.query(sqlMessages))?.rows; const ids: string[] = messagesData .filter((message) => !!message.source_id) .map((message) => message.source_id.replace('WAID:', '')); const savedMessages = await this.prismaRepository.message.findMany({ where: { Instance: { name: instance.instanceName }, messageTimestamp: { gte: dayjs().subtract(6, 'hours').unix() }, AND: ids.map((id) => ({ key: { path: ['id'], not: id } })), }, }); const filteredMessages = savedMessages.filter( (msg: any) => !chatwootImport.isIgnorePhoneNumber(msg.key?.remoteJid), ); const messagesRaw: any[] = []; for (const m of filteredMessages) { if (!m.message || !m.key || !m.messageTimestamp) { continue; } if (Long.isLong(m?.messageTimestamp)) { m.messageTimestamp = m.messageTimestamp?.toNumber(); } messagesRaw.push(prepareMessage(m as any)); } this.addHistoryMessages( instance, messagesRaw.filter((msg) => !chatwootImport.isIgnorePhoneNumber(msg.key?.remoteJid)), ); await chatwootImport.importHistoryMessages(instance, this, inbox, this.provider); const waInstance = this.waMonitor.waInstances[instance.instanceName]; waInstance.clearCacheChatwoot(); } catch (error) { return; } } }