evolution-api/src/api/integrations/chatbot/chatwoot/services/chatwoot.service.ts
2025-06-14 08:35:37 -03:00

2625 lines
79 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<ChatwootModel | null> {
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<HttpServer>('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<ChatwootDto> {
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>('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>('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<string, Promise<number>>();
public async createConversation(instance: InstanceDto, body: any): Promise<number> {
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<number> => {
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<number> {
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<inbox | null> {
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<conversation> {
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>('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(/(?<!\*)\*((?!\s)([^\n*]+?)(?<!\s))\*(?!\*)/g, '_$1_') // Substitui * por _
.replaceAll(/\*{2}((?!\s)([^\n*]+?)(?<!\s))\*{2}/g, '*$1*') // Substitui ** por *
.replaceAll(/~{2}((?!\s)([^\n*]+?)(?<!\s))~{2}/g, '~$1~') // Substitui ~~ por ~
.replaceAll(/(?<!`)`((?!\s)([^`*]+?)(?<!\s))`(?!`)/g, '```$1```') // Substitui ` por ```
: body.content;
const senderName = body?.conversation?.messages[0]?.sender?.available_name || body?.sender?.name;
const waInstance = this.waMonitor.waInstances[instance.instanceName];
if (body.event === 'message_updated' && body.content_attributes?.deleted) {
const message = await this.prismaRepository.message.findFirst({
where: {
chatwootMessageId: body.id,
instanceId: instance.instanceId,
},
});
if (message) {
const key = message.key as {
id: string;
remoteJid: string;
fromMe: boolean;
participant: string;
};
await waInstance?.client.sendMessage(key.remoteJid, { delete: key });
await this.prismaRepository.message.deleteMany({
where: {
instanceId: instance.instanceId,
chatwootMessageId: body.id,
},
});
}
return { message: 'bot' };
}
const cwBotContact = this.configService.get<Chatwoot>('CHATWOOT').BOT_CONTACT;
if (chatId === '123456' && body.message_type === 'outgoing') {
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>('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<MessageModel> {
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<Quoted> {
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*]+?)(?<!\s))\*/g, '**$1**')
.replaceAll(/_((?!\s)([^\n_]+?)(?<!\s))_/g, '*$1*')
.replaceAll(/~((?!\s)([^\n~]+?)(?<!\s))~/g, '~~$1~~')
: originalMessage;
if (bodyMessage && bodyMessage.includes('Por favor, classifique esta conversa, http')) {
return;
}
const quotedId = body.contextInfo?.stanzaId || body.message?.contextInfo?.stanzaId;
let quotedMsg = null;
if (quotedId)
quotedMsg = await this.prismaRepository.message.findFirst({
where: {
key: {
path: ['id'],
equals: quotedId,
},
chatwootMessageId: {
not: null,
},
},
});
const isMedia = this.isMediaMessage(body.message);
const adsMessage = this.getAdsMessage(body);
const reactionMessage = this.getReactionMessage(body.message);
if (!bodyMessage && !isMedia && !reactionMessage) {
this.logger.warn('no body message found');
return;
}
const getConversation = await this.createConversation(instance, body);
if (!getConversation) {
this.logger.warn('conversation not found');
return;
}
const messageType = body.key.fromMe ? 'outgoing' : 'incoming';
if (isMedia) {
const downloadBase64 = await waInstance?.getBase64FromMediaMessage({
message: {
...body,
},
});
let nameFile: string;
const messageBody = body?.message[body?.messageType];
const originalFilename =
messageBody?.fileName || messageBody?.filename || messageBody?.message?.documentMessage?.fileName;
if (originalFilename) {
const parsedFile = path.parse(originalFilename);
if (parsedFile.name && parsedFile.ext) {
nameFile = `${parsedFile.name}-${Math.floor(Math.random() * (99 - 10 + 1) + 10)}${parsedFile.ext}`;
}
}
if (!nameFile) {
nameFile = `${Math.random().toString(36).substring(7)}.${mimeTypes.extension(downloadBase64.mimetype) || ''}`;
}
const fileData = Buffer.from(downloadBase64.base64, 'base64');
const fileStream = new Readable();
fileStream._read = () => { };
fileStream.push(fileData);
fileStream.push(null);
if (body.key.remoteJid.includes('@g.us')) {
const participantName = body.pushName;
const rawPhoneNumber = body.key.participant.split('@')[0];
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>('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>('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<string, ContactModel>, 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>('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;
}
}
}