mirror of
https://github.com/EvolutionAPI/evolution-api.git
synced 2026-01-10 22:02:21 -06:00
6151 lines
182 KiB
TypeScript
6151 lines
182 KiB
TypeScript
import { getCollectionsDto } from "@api/dto/business.dto";
|
||
import { OfferCallDto } from "@api/dto/call.dto";
|
||
import {
|
||
ArchiveChatDto,
|
||
BlockUserDto,
|
||
DeleteMessage,
|
||
getBase64FromMediaMessageDto,
|
||
LastMessage,
|
||
MarkChatUnreadDto,
|
||
NumberBusiness,
|
||
OnWhatsAppDto,
|
||
PrivacySettingDto,
|
||
ReadMessageDto,
|
||
SendPresenceDto,
|
||
UpdateMessageDto,
|
||
WhatsAppNumberDto,
|
||
} from "@api/dto/chat.dto";
|
||
import {
|
||
AcceptGroupInvite,
|
||
CreateGroupDto,
|
||
GetParticipant,
|
||
GroupDescriptionDto,
|
||
GroupInvite,
|
||
GroupJid,
|
||
GroupPictureDto,
|
||
GroupSendInvite,
|
||
GroupSubjectDto,
|
||
GroupToggleEphemeralDto,
|
||
GroupUpdateParticipantDto,
|
||
GroupUpdateSettingDto,
|
||
} from "@api/dto/group.dto";
|
||
import { InstanceDto, SetPresenceDto } from "@api/dto/instance.dto";
|
||
import { HandleLabelDto, LabelDto } from "@api/dto/label.dto";
|
||
import {
|
||
Button,
|
||
ContactMessage,
|
||
KeyType,
|
||
MediaMessage,
|
||
Options,
|
||
SendAudioDto,
|
||
SendButtonsDto,
|
||
SendContactDto,
|
||
SendListDto,
|
||
SendLocationDto,
|
||
SendMediaDto,
|
||
SendPollDto,
|
||
SendPtvDto,
|
||
SendReactionDto,
|
||
SendStatusDto,
|
||
SendStickerDto,
|
||
SendTextDto,
|
||
StatusMessage,
|
||
TypeButton,
|
||
} from "@api/dto/sendMessage.dto";
|
||
import { chatwootImport } from "@api/integrations/chatbot/chatwoot/utils/chatwoot-import-helper";
|
||
import * as s3Service from "@api/integrations/storage/s3/libs/minio.server";
|
||
import { ProviderFiles } from "@api/provider/sessions";
|
||
import { PrismaRepository, Query } from "@api/repository/repository.service";
|
||
import { chatbotController, waMonitor } from "@api/server.module";
|
||
import { CacheService } from "@api/services/cache.service";
|
||
import { ChannelStartupService } from "@api/services/channel.service";
|
||
import {
|
||
Events,
|
||
MessageSubtype,
|
||
TypeMediaMessage,
|
||
wa,
|
||
} from "@api/types/wa.types";
|
||
import { CacheEngine } from "@cache/cacheengine";
|
||
import {
|
||
AudioConverter,
|
||
CacheConf,
|
||
Chatwoot,
|
||
ConfigService,
|
||
configService,
|
||
ConfigSessionPhone,
|
||
Database,
|
||
Log,
|
||
Openai,
|
||
ProviderSession,
|
||
QrCode,
|
||
S3,
|
||
} from "@config/env.config";
|
||
import {
|
||
BadRequestException,
|
||
InternalServerErrorException,
|
||
NotFoundException,
|
||
} from "@exceptions";
|
||
import ffmpegPath from "@ffmpeg-installer/ffmpeg";
|
||
import { Boom } from "@hapi/boom";
|
||
import { createId as cuid } from "@paralleldrive/cuid2";
|
||
import { Instance, Message } from "@prisma/client";
|
||
import { createJid } from "@utils/createJid";
|
||
import { fetchLatestWaWebVersion } from "@utils/fetchLatestWaWebVersion";
|
||
import { makeProxyAgent } from "@utils/makeProxyAgent";
|
||
import {
|
||
getOnWhatsappCache,
|
||
saveOnWhatsappCache,
|
||
} from "@utils/onWhatsappCache";
|
||
import { status } from "@utils/renderStatus";
|
||
import { sendTelemetry } from "@utils/sendTelemetry";
|
||
import useMultiFileAuthStatePrisma from "@utils/use-multi-file-auth-state-prisma";
|
||
import { AuthStateProvider } from "@utils/use-multi-file-auth-state-provider-files";
|
||
import { useMultiFileAuthStateRedisDb } from "@utils/use-multi-file-auth-state-redis-db";
|
||
import axios from "axios";
|
||
import makeWASocket, {
|
||
AnyMessageContent,
|
||
BufferedEventData,
|
||
BufferJSON,
|
||
CacheStore,
|
||
CatalogCollection,
|
||
Chat,
|
||
ConnectionState,
|
||
Contact,
|
||
delay,
|
||
DisconnectReason,
|
||
downloadContentFromMessage,
|
||
downloadMediaMessage,
|
||
generateWAMessageFromContent,
|
||
getAggregateVotesInPollMessage,
|
||
GetCatalogOptions,
|
||
getContentType,
|
||
getDevice,
|
||
GroupMetadata,
|
||
isJidBroadcast,
|
||
isJidGroup,
|
||
isJidNewsletter,
|
||
isPnUser,
|
||
makeCacheableSignalKeyStore,
|
||
MessageUpsertType,
|
||
MessageUserReceiptUpdate,
|
||
MiscMessageGenerationOptions,
|
||
ParticipantAction,
|
||
prepareWAMessageMedia,
|
||
Product,
|
||
proto,
|
||
UserFacingSocketConfig,
|
||
WABrowserDescription,
|
||
WAMediaUpload,
|
||
WAMessage,
|
||
WAMessageKey,
|
||
WAPresence,
|
||
WASocket,
|
||
} from "baileys";
|
||
import { Label } from "baileys/lib/Types/Label";
|
||
import { LabelAssociation } from "baileys/lib/Types/LabelAssociation";
|
||
import { spawn } from "child_process";
|
||
import { isArray, isBase64, isURL } from "class-validator";
|
||
import EventEmitter2 from "eventemitter2";
|
||
import ffmpeg from "fluent-ffmpeg";
|
||
import FormData from "form-data";
|
||
import Long from "long";
|
||
import mimeTypes from "mime-types";
|
||
import NodeCache from "node-cache";
|
||
import cron from "node-cron";
|
||
import { release } from "os";
|
||
import { join } from "path";
|
||
import P from "pino";
|
||
import qrcode, { QRCodeToDataURLOptions } from "qrcode";
|
||
import qrcodeTerminal from "qrcode-terminal";
|
||
import sharp from "sharp";
|
||
import { PassThrough, Readable } from "stream";
|
||
import { v4 } from "uuid";
|
||
|
||
import { BaileysMessageProcessor } from "./baileysMessage.processor";
|
||
import { useVoiceCallsBaileys } from "./voiceCalls/useVoiceCallsBaileys";
|
||
|
||
export interface ExtendedIMessageKey extends proto.IMessageKey {
|
||
remoteJidAlt?: string;
|
||
participantAlt?: string;
|
||
server_id?: string;
|
||
isViewOnce?: boolean;
|
||
}
|
||
|
||
const groupMetadataCache = new CacheService(
|
||
new CacheEngine(configService, "groups").getEngine()
|
||
);
|
||
|
||
// Adicione a função getVideoDuration no início do arquivo
|
||
async function getVideoDuration(
|
||
input: Buffer | string | Readable
|
||
): Promise<number> {
|
||
const MediaInfoFactory = (await import("mediainfo.js")).default;
|
||
const mediainfo = await MediaInfoFactory({ format: "JSON" });
|
||
|
||
let fileSize: number;
|
||
let readChunk: (size: number, offset: number) => Promise<Buffer>;
|
||
|
||
if (Buffer.isBuffer(input)) {
|
||
fileSize = input.length;
|
||
readChunk = async (size: number, offset: number): Promise<Buffer> => {
|
||
return input.slice(offset, offset + size);
|
||
};
|
||
} else if (typeof input === "string") {
|
||
const fs = await import("fs");
|
||
const stat = await fs.promises.stat(input);
|
||
fileSize = stat.size;
|
||
const fd = await fs.promises.open(input, "r");
|
||
|
||
readChunk = async (size: number, offset: number): Promise<Buffer> => {
|
||
const buffer = Buffer.alloc(size);
|
||
await fd.read(buffer, 0, size, offset);
|
||
return buffer;
|
||
};
|
||
|
||
try {
|
||
const result = await mediainfo.analyzeData(() => fileSize, readChunk);
|
||
const jsonResult = JSON.parse(result);
|
||
|
||
const generalTrack = jsonResult.media.track.find(
|
||
(t: any) => t["@type"] === "General"
|
||
);
|
||
const duration = generalTrack.Duration;
|
||
|
||
return Math.round(parseFloat(duration));
|
||
} finally {
|
||
await fd.close();
|
||
}
|
||
} else if (input instanceof Readable) {
|
||
const chunks: Buffer[] = [];
|
||
for await (const chunk of input) {
|
||
chunks.push(chunk);
|
||
}
|
||
const data = Buffer.concat(chunks);
|
||
fileSize = data.length;
|
||
|
||
readChunk = async (size: number, offset: number): Promise<Buffer> => {
|
||
return data.slice(offset, offset + size);
|
||
};
|
||
} else {
|
||
throw new Error("Tipo de entrada não suportado");
|
||
}
|
||
|
||
const result = await mediainfo.analyzeData(() => fileSize, readChunk);
|
||
const jsonResult = JSON.parse(result);
|
||
|
||
const generalTrack = jsonResult.media.track.find(
|
||
(t: any) => t["@type"] === "General"
|
||
);
|
||
const duration = generalTrack.Duration;
|
||
|
||
return Math.round(parseFloat(duration));
|
||
}
|
||
|
||
export class BaileysStartupService extends ChannelStartupService {
|
||
private messageProcessor = new BaileysMessageProcessor();
|
||
|
||
constructor(
|
||
public readonly configService: ConfigService,
|
||
public readonly eventEmitter: EventEmitter2,
|
||
public readonly prismaRepository: PrismaRepository,
|
||
public readonly cache: CacheService,
|
||
public readonly chatwootCache: CacheService,
|
||
public readonly baileysCache: CacheService,
|
||
private readonly providerFiles: ProviderFiles
|
||
) {
|
||
super(configService, eventEmitter, prismaRepository, chatwootCache);
|
||
this.instance.qrcode = { count: 0 };
|
||
this.messageProcessor.mount({
|
||
onMessageReceive: this.messageHandle["messages.upsert"].bind(this), // Bind the method to the current context
|
||
});
|
||
|
||
this.authStateProvider = new AuthStateProvider(this.providerFiles);
|
||
}
|
||
|
||
private authStateProvider: AuthStateProvider;
|
||
private readonly msgRetryCounterCache: CacheStore = new NodeCache();
|
||
private readonly userDevicesCache: CacheStore = new NodeCache({
|
||
stdTTL: 300000,
|
||
useClones: false,
|
||
});
|
||
private endSession = false;
|
||
private logBaileys = this.configService.get<Log>("LOG").BAILEYS;
|
||
|
||
// Cache TTL constants (in seconds)
|
||
private readonly MESSAGE_CACHE_TTL_SECONDS = 5 * 60; // 5 minutes - avoid duplicate message processing
|
||
private readonly UPDATE_CACHE_TTL_SECONDS = 30 * 60; // 30 minutes - avoid duplicate status updates
|
||
|
||
public stateConnection: wa.StateConnection = { state: "close" };
|
||
|
||
public phoneNumber: string;
|
||
|
||
public get connectionStatus() {
|
||
return this.stateConnection;
|
||
}
|
||
|
||
public async logoutInstance() {
|
||
this.messageProcessor.onDestroy();
|
||
await this.client?.logout("Log out instance: " + this.instanceName);
|
||
|
||
this.client?.ws?.close();
|
||
|
||
const sessionExists = await this.prismaRepository.session.findFirst({
|
||
where: { sessionId: this.instanceId },
|
||
});
|
||
if (sessionExists) {
|
||
await this.prismaRepository.session.delete({
|
||
where: { sessionId: this.instanceId },
|
||
});
|
||
}
|
||
}
|
||
|
||
public async getProfileName() {
|
||
let profileName = this.client.user?.name ?? this.client.user?.verifiedName;
|
||
if (!profileName) {
|
||
const data = await this.prismaRepository.session.findUnique({
|
||
where: { sessionId: this.instanceId },
|
||
});
|
||
|
||
if (data) {
|
||
const creds = JSON.parse(
|
||
JSON.stringify(data.creds),
|
||
BufferJSON.reviver
|
||
);
|
||
profileName = creds.me?.name || creds.me?.verifiedName;
|
||
}
|
||
}
|
||
|
||
return profileName;
|
||
}
|
||
|
||
public async getProfileStatus() {
|
||
const status = await this.client.fetchStatus(this.instance.wuid);
|
||
|
||
return status[0]?.status;
|
||
}
|
||
|
||
public get profilePictureUrl() {
|
||
return this.instance.profilePictureUrl;
|
||
}
|
||
|
||
public get qrCode(): wa.QrCode {
|
||
return {
|
||
pairingCode: this.instance.qrcode?.pairingCode,
|
||
code: this.instance.qrcode?.code,
|
||
base64: this.instance.qrcode?.base64,
|
||
count: this.instance.qrcode?.count,
|
||
};
|
||
}
|
||
|
||
private async connectionUpdate({
|
||
qr,
|
||
connection,
|
||
lastDisconnect,
|
||
}: Partial<ConnectionState>) {
|
||
if (qr) {
|
||
if (
|
||
this.instance.qrcode.count ===
|
||
this.configService.get<QrCode>("QRCODE").LIMIT
|
||
) {
|
||
this.sendDataWebhook(Events.QRCODE_UPDATED, {
|
||
message: "QR code limit reached, please login again",
|
||
statusCode: DisconnectReason.badSession,
|
||
});
|
||
|
||
if (
|
||
this.configService.get<Chatwoot>("CHATWOOT").ENABLED &&
|
||
this.localChatwoot?.enabled
|
||
) {
|
||
this.chatwootService.eventWhatsapp(
|
||
Events.QRCODE_UPDATED,
|
||
{ instanceName: this.instance.name, instanceId: this.instanceId },
|
||
{
|
||
message: "QR code limit reached, please login again",
|
||
statusCode: DisconnectReason.badSession,
|
||
}
|
||
);
|
||
}
|
||
|
||
this.sendDataWebhook(Events.CONNECTION_UPDATE, {
|
||
instance: this.instance.name,
|
||
state: "refused",
|
||
statusReason: DisconnectReason.connectionClosed,
|
||
wuid: this.instance.wuid,
|
||
profileName: await this.getProfileName(),
|
||
profilePictureUrl: this.instance.profilePictureUrl,
|
||
});
|
||
|
||
this.endSession = true;
|
||
|
||
return this.eventEmitter.emit("no.connection", this.instance.name);
|
||
}
|
||
|
||
this.instance.qrcode.count++;
|
||
|
||
const color = this.configService.get<QrCode>("QRCODE").COLOR;
|
||
|
||
const optsQrcode: QRCodeToDataURLOptions = {
|
||
margin: 3,
|
||
scale: 4,
|
||
errorCorrectionLevel: "H",
|
||
color: { light: "#ffffff", dark: color },
|
||
};
|
||
|
||
if (this.phoneNumber) {
|
||
await delay(1000);
|
||
this.instance.qrcode.pairingCode = await this.client.requestPairingCode(
|
||
this.phoneNumber
|
||
);
|
||
} else {
|
||
this.instance.qrcode.pairingCode = null;
|
||
}
|
||
|
||
qrcode.toDataURL(qr, optsQrcode, (error, base64) => {
|
||
if (error) {
|
||
this.logger.error("Qrcode generate failed:" + error.toString());
|
||
return;
|
||
}
|
||
|
||
this.instance.qrcode.base64 = base64;
|
||
this.instance.qrcode.code = qr;
|
||
|
||
this.sendDataWebhook(Events.QRCODE_UPDATED, {
|
||
qrcode: {
|
||
instance: this.instance.name,
|
||
pairingCode: this.instance.qrcode.pairingCode,
|
||
code: qr,
|
||
base64,
|
||
},
|
||
});
|
||
|
||
if (
|
||
this.configService.get<Chatwoot>("CHATWOOT").ENABLED &&
|
||
this.localChatwoot?.enabled
|
||
) {
|
||
this.chatwootService.eventWhatsapp(
|
||
Events.QRCODE_UPDATED,
|
||
{ instanceName: this.instance.name, instanceId: this.instanceId },
|
||
{
|
||
qrcode: {
|
||
instance: this.instance.name,
|
||
pairingCode: this.instance.qrcode.pairingCode,
|
||
code: qr,
|
||
base64,
|
||
},
|
||
}
|
||
);
|
||
}
|
||
});
|
||
|
||
qrcodeTerminal.generate(qr, { small: true }, (qrcode) =>
|
||
this.logger.log(
|
||
`\n{ instance: ${this.instance.name} pairingCode: ${this.instance.qrcode.pairingCode}, qrcodeCount: ${this.instance.qrcode.count} }\n` +
|
||
qrcode
|
||
)
|
||
);
|
||
|
||
await this.prismaRepository.instance.update({
|
||
where: { id: this.instanceId },
|
||
data: { connectionStatus: "connecting" },
|
||
});
|
||
}
|
||
|
||
if (connection) {
|
||
this.stateConnection = {
|
||
state: connection,
|
||
statusReason:
|
||
(lastDisconnect?.error as Boom)?.output?.statusCode ?? 200,
|
||
};
|
||
}
|
||
|
||
if (connection === "close") {
|
||
const statusCode = (lastDisconnect?.error as Boom)?.output?.statusCode;
|
||
const codesToNotReconnect = [
|
||
DisconnectReason.loggedOut,
|
||
DisconnectReason.forbidden,
|
||
402,
|
||
406,
|
||
];
|
||
const shouldReconnect = !codesToNotReconnect.includes(statusCode);
|
||
if (shouldReconnect) {
|
||
await this.connectToWhatsapp(this.phoneNumber);
|
||
} else {
|
||
this.sendDataWebhook(Events.STATUS_INSTANCE, {
|
||
instance: this.instance.name,
|
||
status: "closed",
|
||
disconnectionAt: new Date(),
|
||
disconnectionReasonCode: statusCode,
|
||
disconnectionObject: JSON.stringify(lastDisconnect),
|
||
});
|
||
|
||
await this.prismaRepository.instance.update({
|
||
where: { id: this.instanceId },
|
||
data: {
|
||
connectionStatus: "close",
|
||
disconnectionAt: new Date(),
|
||
disconnectionReasonCode: statusCode,
|
||
disconnectionObject: JSON.stringify(lastDisconnect),
|
||
},
|
||
});
|
||
|
||
if (
|
||
this.configService.get<Chatwoot>("CHATWOOT").ENABLED &&
|
||
this.localChatwoot?.enabled
|
||
) {
|
||
this.chatwootService.eventWhatsapp(
|
||
Events.STATUS_INSTANCE,
|
||
{ instanceName: this.instance.name, instanceId: this.instanceId },
|
||
{ instance: this.instance.name, status: "closed" }
|
||
);
|
||
}
|
||
|
||
this.eventEmitter.emit("logout.instance", this.instance.name, "inner");
|
||
this.client?.ws?.close();
|
||
this.client.end(new Error("Close connection"));
|
||
|
||
this.sendDataWebhook(Events.CONNECTION_UPDATE, {
|
||
instance: this.instance.name,
|
||
...this.stateConnection,
|
||
});
|
||
}
|
||
}
|
||
|
||
if (connection === "open") {
|
||
this.instance.wuid = this.client.user.id.replace(/:\d+/, "");
|
||
try {
|
||
const profilePic = await this.profilePicture(this.instance.wuid);
|
||
this.instance.profilePictureUrl = profilePic.profilePictureUrl;
|
||
} catch {
|
||
this.instance.profilePictureUrl = null;
|
||
}
|
||
const formattedWuid = this.instance.wuid.split("@")[0].padEnd(30, " ");
|
||
const formattedName = this.instance.name;
|
||
this.logger.info(
|
||
`
|
||
┌──────────────────────────────┐
|
||
│ CONNECTED TO WHATSAPP │
|
||
└──────────────────────────────┘`.replace(/^ +/gm, " ")
|
||
);
|
||
this.logger.info(
|
||
`
|
||
wuid: ${formattedWuid}
|
||
name: ${formattedName}
|
||
`
|
||
);
|
||
|
||
await this.prismaRepository.instance.update({
|
||
where: { id: this.instanceId },
|
||
data: {
|
||
ownerJid: this.instance.wuid,
|
||
profileName: (await this.getProfileName()) as string,
|
||
profilePicUrl: this.instance.profilePictureUrl,
|
||
connectionStatus: "open",
|
||
},
|
||
});
|
||
|
||
if (
|
||
this.configService.get<Chatwoot>("CHATWOOT").ENABLED &&
|
||
this.localChatwoot?.enabled
|
||
) {
|
||
this.chatwootService.eventWhatsapp(
|
||
Events.CONNECTION_UPDATE,
|
||
{ instanceName: this.instance.name, instanceId: this.instanceId },
|
||
{ instance: this.instance.name, status: "open" }
|
||
);
|
||
this.syncChatwootLostMessages();
|
||
}
|
||
|
||
this.sendDataWebhook(Events.CONNECTION_UPDATE, {
|
||
instance: this.instance.name,
|
||
wuid: this.instance.wuid,
|
||
profileName: await this.getProfileName(),
|
||
profilePictureUrl: this.instance.profilePictureUrl,
|
||
...this.stateConnection,
|
||
});
|
||
}
|
||
|
||
if (connection === "connecting") {
|
||
this.sendDataWebhook(Events.CONNECTION_UPDATE, {
|
||
instance: this.instance.name,
|
||
...this.stateConnection,
|
||
});
|
||
}
|
||
}
|
||
|
||
private async getMessage(key: proto.IMessageKey, full = false) {
|
||
try {
|
||
// Use raw SQL to avoid JSON path issues
|
||
const webMessageInfo = (await this.prismaRepository.$queryRaw`
|
||
SELECT * FROM "Message"
|
||
WHERE "instanceId" = ${this.instanceId}
|
||
AND "key"->>'id' = ${key.id}
|
||
`) as proto.IWebMessageInfo[];
|
||
|
||
if (full) {
|
||
return webMessageInfo[0];
|
||
}
|
||
if (webMessageInfo[0].message?.pollCreationMessage) {
|
||
const messageSecretBase64 =
|
||
webMessageInfo[0].message?.messageContextInfo?.messageSecret;
|
||
|
||
if (typeof messageSecretBase64 === "string") {
|
||
const messageSecret = Buffer.from(messageSecretBase64, "base64");
|
||
|
||
const msg = {
|
||
messageContextInfo: { messageSecret },
|
||
pollCreationMessage: webMessageInfo[0].message?.pollCreationMessage,
|
||
};
|
||
|
||
return msg;
|
||
}
|
||
}
|
||
|
||
return webMessageInfo[0].message;
|
||
} catch {
|
||
return { conversation: "" };
|
||
}
|
||
}
|
||
|
||
private async defineAuthState() {
|
||
const db = this.configService.get<Database>("DATABASE");
|
||
const cache = this.configService.get<CacheConf>("CACHE");
|
||
|
||
const provider = this.configService.get<ProviderSession>("PROVIDER");
|
||
|
||
if (provider?.ENABLED) {
|
||
return await this.authStateProvider.authStateProvider(this.instance.id);
|
||
}
|
||
|
||
if (cache?.REDIS.ENABLED && cache?.REDIS.SAVE_INSTANCES) {
|
||
this.logger.info("Redis enabled");
|
||
return await useMultiFileAuthStateRedisDb(this.instance.id, this.cache);
|
||
}
|
||
|
||
if (db.SAVE_DATA.INSTANCE) {
|
||
return await useMultiFileAuthStatePrisma(this.instance.id, this.cache);
|
||
}
|
||
}
|
||
|
||
private async createClient(number?: string): Promise<WASocket> {
|
||
this.instance.authState = await this.defineAuthState();
|
||
|
||
const session = this.configService.get<ConfigSessionPhone>(
|
||
"CONFIG_SESSION_PHONE"
|
||
);
|
||
|
||
let browserOptions = {};
|
||
|
||
if (number || this.phoneNumber) {
|
||
this.phoneNumber = number;
|
||
|
||
this.logger.info(`Phone number: ${number}`);
|
||
} else {
|
||
const browser: WABrowserDescription = [
|
||
session.CLIENT,
|
||
session.NAME,
|
||
release(),
|
||
];
|
||
browserOptions = { browser };
|
||
|
||
this.logger.info(`Browser: ${browser}`);
|
||
}
|
||
|
||
const baileysVersion = await fetchLatestWaWebVersion({});
|
||
const version = baileysVersion.version;
|
||
const log = `Baileys version: ${version.join(".")}`;
|
||
|
||
// if (session.VERSION) {
|
||
// version = session.VERSION.split('.');
|
||
// log = `Baileys version env: ${version}`;
|
||
// } else {
|
||
// const baileysVersion = await fetchLatestWaWebVersion({});
|
||
// version = baileysVersion.version;
|
||
// log = `Baileys version: ${version}`;
|
||
// }
|
||
|
||
this.logger.info(log);
|
||
|
||
this.logger.info(`Group Ignore: ${this.localSettings.groupsIgnore}`);
|
||
|
||
let options;
|
||
|
||
if (this.localProxy?.enabled) {
|
||
this.logger.info("Proxy enabled: " + this.localProxy?.host);
|
||
|
||
if (this.localProxy?.host?.includes("proxyscrape")) {
|
||
try {
|
||
const response = await axios.get(this.localProxy?.host);
|
||
const text = response.data;
|
||
const proxyUrls = text.split("\r\n");
|
||
const rand = Math.floor(Math.random() * Math.floor(proxyUrls.length));
|
||
const proxyUrl = "http://" + proxyUrls[rand];
|
||
options = {
|
||
agent: makeProxyAgent(proxyUrl),
|
||
fetchAgent: makeProxyAgent(proxyUrl),
|
||
};
|
||
} catch {
|
||
this.localProxy.enabled = false;
|
||
}
|
||
} else {
|
||
options = {
|
||
agent: makeProxyAgent({
|
||
host: this.localProxy.host,
|
||
port: this.localProxy.port,
|
||
protocol: this.localProxy.protocol,
|
||
username: this.localProxy.username,
|
||
password: this.localProxy.password,
|
||
}),
|
||
fetchAgent: makeProxyAgentUndici({
|
||
host: this.localProxy.host,
|
||
port: this.localProxy.port,
|
||
protocol: this.localProxy.protocol,
|
||
username: this.localProxy.username,
|
||
password: this.localProxy.password,
|
||
}),
|
||
};
|
||
}
|
||
}
|
||
|
||
const socketConfig: UserFacingSocketConfig = {
|
||
...options,
|
||
version,
|
||
logger: P({ level: this.logBaileys }),
|
||
printQRInTerminal: false,
|
||
auth: {
|
||
creds: this.instance.authState.state.creds,
|
||
keys: makeCacheableSignalKeyStore(
|
||
this.instance.authState.state.keys,
|
||
P({ level: "error" }) as any
|
||
),
|
||
},
|
||
msgRetryCounterCache: this.msgRetryCounterCache,
|
||
generateHighQualityLinkPreview: true,
|
||
getMessage: async (key) =>
|
||
(await this.getMessage(key)) as Promise<proto.IMessage>,
|
||
...browserOptions,
|
||
markOnlineOnConnect: this.localSettings.alwaysOnline,
|
||
retryRequestDelayMs: 350,
|
||
maxMsgRetryCount: 4,
|
||
fireInitQueries: true,
|
||
connectTimeoutMs: 30_000,
|
||
keepAliveIntervalMs: 30_000,
|
||
qrTimeout: 45_000,
|
||
emitOwnEvents: false,
|
||
shouldIgnoreJid: (jid) => {
|
||
if (this.localSettings.syncFullHistory && isJidGroup(jid)) {
|
||
return false;
|
||
}
|
||
|
||
const isGroupJid = this.localSettings.groupsIgnore && isJidGroup(jid);
|
||
const isBroadcast =
|
||
!this.localSettings.readStatus && isJidBroadcast(jid);
|
||
const isNewsletter = isJidNewsletter(jid);
|
||
|
||
return isGroupJid || isBroadcast || isNewsletter;
|
||
},
|
||
syncFullHistory: this.localSettings.syncFullHistory,
|
||
shouldSyncHistoryMessage: (
|
||
msg: proto.Message.IHistorySyncNotification
|
||
) => {
|
||
return this.historySyncNotification(msg);
|
||
},
|
||
cachedGroupMetadata: this.getGroupMetadataCache,
|
||
userDevicesCache: this.userDevicesCache,
|
||
transactionOpts: { maxCommitRetries: 10, delayBetweenTriesMs: 3000 },
|
||
patchMessageBeforeSending(message) {
|
||
if (
|
||
message.deviceSentMessage?.message?.listMessage?.listType ===
|
||
proto.Message.ListMessage.ListType.PRODUCT_LIST
|
||
) {
|
||
message = JSON.parse(JSON.stringify(message));
|
||
|
||
message.deviceSentMessage.message.listMessage.listType =
|
||
proto.Message.ListMessage.ListType.SINGLE_SELECT;
|
||
}
|
||
|
||
if (
|
||
message.listMessage?.listType ==
|
||
proto.Message.ListMessage.ListType.PRODUCT_LIST
|
||
) {
|
||
message = JSON.parse(JSON.stringify(message));
|
||
|
||
message.listMessage.listType =
|
||
proto.Message.ListMessage.ListType.SINGLE_SELECT;
|
||
}
|
||
|
||
return message;
|
||
},
|
||
};
|
||
|
||
this.endSession = false;
|
||
|
||
this.client = makeWASocket(socketConfig);
|
||
|
||
if (
|
||
this.localSettings.wavoipToken &&
|
||
this.localSettings.wavoipToken.length > 0
|
||
) {
|
||
useVoiceCallsBaileys(
|
||
this.localSettings.wavoipToken,
|
||
this.client,
|
||
this.connectionStatus.state as any,
|
||
true
|
||
);
|
||
}
|
||
|
||
this.eventHandler();
|
||
|
||
this.client.ws.on("CB:call", (packet) => {
|
||
console.log("CB:call", packet);
|
||
const payload = { event: "CB:call", packet: packet };
|
||
this.sendDataWebhook(Events.CALL, payload, true, ["websocket"]);
|
||
});
|
||
|
||
this.client.ws.on("CB:ack,class:call", (packet) => {
|
||
console.log("CB:ack,class:call", packet);
|
||
const payload = { event: "CB:ack,class:call", packet: packet };
|
||
this.sendDataWebhook(Events.CALL, payload, true, ["websocket"]);
|
||
});
|
||
|
||
this.phoneNumber = number;
|
||
|
||
return this.client;
|
||
}
|
||
|
||
public async connectToWhatsapp(number?: string): Promise<WASocket> {
|
||
try {
|
||
this.loadChatwoot();
|
||
this.loadSettings();
|
||
this.loadWebhook();
|
||
this.loadProxy();
|
||
|
||
return await this.createClient(number);
|
||
} catch (error) {
|
||
this.logger.error(error);
|
||
throw new InternalServerErrorException(error?.toString());
|
||
}
|
||
}
|
||
|
||
public async reloadConnection(): Promise<WASocket> {
|
||
try {
|
||
return await this.createClient(this.phoneNumber);
|
||
} catch (error) {
|
||
this.logger.error(error);
|
||
throw new InternalServerErrorException(error?.toString());
|
||
}
|
||
}
|
||
|
||
private readonly chatHandle = {
|
||
"chats.upsert": async (chats: Chat[]) => {
|
||
const existingChatIds = await this.prismaRepository.chat.findMany({
|
||
where: { instanceId: this.instanceId },
|
||
select: { remoteJid: true },
|
||
});
|
||
|
||
const existingChatIdSet = new Set(
|
||
existingChatIds.map((chat) => chat.remoteJid)
|
||
);
|
||
|
||
const chatsToInsert = chats
|
||
.filter((chat) => !existingChatIdSet?.has(chat.id))
|
||
.map((chat) => ({
|
||
remoteJid: chat.id,
|
||
instanceId: this.instanceId,
|
||
name: chat.name,
|
||
unreadMessages: chat.unreadCount !== undefined ? chat.unreadCount : 0,
|
||
}));
|
||
|
||
this.sendDataWebhook(Events.CHATS_UPSERT, chatsToInsert);
|
||
|
||
if (chatsToInsert.length > 0) {
|
||
if (this.configService.get<Database>("DATABASE").SAVE_DATA.CHATS)
|
||
await this.prismaRepository.chat.createMany({
|
||
data: chatsToInsert,
|
||
skipDuplicates: true,
|
||
});
|
||
}
|
||
},
|
||
|
||
"chats.update": async (
|
||
chats: Partial<
|
||
proto.IConversation & { lastMessageRecvTimestamp?: number } & {
|
||
conditional: (bufferedData: BufferedEventData) => boolean;
|
||
}
|
||
>[]
|
||
) => {
|
||
const chatsRaw = chats.map((chat) => {
|
||
return { remoteJid: chat.id, instanceId: this.instanceId };
|
||
});
|
||
|
||
this.sendDataWebhook(Events.CHATS_UPDATE, chatsRaw);
|
||
|
||
for (const chat of chats) {
|
||
await this.prismaRepository.chat.updateMany({
|
||
where: {
|
||
instanceId: this.instanceId,
|
||
remoteJid: chat.id,
|
||
name: chat.name,
|
||
},
|
||
data: { remoteJid: chat.id },
|
||
});
|
||
}
|
||
},
|
||
|
||
"chats.delete": async (chats: string[]) => {
|
||
chats.forEach(
|
||
async (chat) =>
|
||
await this.prismaRepository.chat.deleteMany({
|
||
where: { instanceId: this.instanceId, remoteJid: chat },
|
||
})
|
||
);
|
||
|
||
this.sendDataWebhook(Events.CHATS_DELETE, [...chats]);
|
||
},
|
||
};
|
||
|
||
private readonly contactHandle = {
|
||
"contacts.upsert": async (contacts: Contact[]) => {
|
||
try {
|
||
const contactsRaw: any = contacts.map((contact) => ({
|
||
remoteJid: contact.id,
|
||
pushName:
|
||
contact?.name || contact?.verifiedName || contact.id.split("@")[0],
|
||
profilePicUrl: null,
|
||
instanceId: this.instanceId,
|
||
}));
|
||
|
||
if (contactsRaw.length > 0) {
|
||
this.sendDataWebhook(Events.CONTACTS_UPSERT, contactsRaw);
|
||
|
||
if (this.configService.get<Database>("DATABASE").SAVE_DATA.CONTACTS)
|
||
await this.prismaRepository.contact.createMany({
|
||
data: contactsRaw,
|
||
skipDuplicates: true,
|
||
});
|
||
|
||
const usersContacts = contactsRaw.filter((c) =>
|
||
c.remoteJid.includes("@s.whatsapp")
|
||
);
|
||
if (usersContacts) {
|
||
await saveOnWhatsappCache(
|
||
usersContacts.map((c) => ({ remoteJid: c.remoteJid }))
|
||
);
|
||
}
|
||
}
|
||
|
||
if (
|
||
this.configService.get<Chatwoot>("CHATWOOT").ENABLED &&
|
||
this.localChatwoot?.enabled &&
|
||
this.localChatwoot.importContacts &&
|
||
contactsRaw.length
|
||
) {
|
||
this.chatwootService.addHistoryContacts(
|
||
{ instanceName: this.instance.name, instanceId: this.instance.id },
|
||
contactsRaw
|
||
);
|
||
chatwootImport.importHistoryContacts(
|
||
{ instanceName: this.instance.name, instanceId: this.instance.id },
|
||
this.localChatwoot
|
||
);
|
||
}
|
||
|
||
const updatedContacts = await Promise.all(
|
||
contacts.map(async (contact) => ({
|
||
remoteJid: contact.id,
|
||
pushName:
|
||
contact?.name ||
|
||
contact?.verifiedName ||
|
||
contact.id.split("@")[0],
|
||
profilePicUrl: (
|
||
await this.profilePicture(contact.id)
|
||
).profilePictureUrl,
|
||
instanceId: this.instanceId,
|
||
}))
|
||
);
|
||
|
||
if (updatedContacts.length > 0) {
|
||
const usersContacts = updatedContacts.filter((c) =>
|
||
c.remoteJid.includes("@s.whatsapp")
|
||
);
|
||
if (usersContacts) {
|
||
await saveOnWhatsappCache(
|
||
usersContacts.map((c) => ({ remoteJid: c.remoteJid }))
|
||
);
|
||
}
|
||
|
||
this.sendDataWebhook(Events.CONTACTS_UPDATE, updatedContacts);
|
||
await Promise.all(
|
||
updatedContacts.map(async (contact) => {
|
||
const update = this.prismaRepository.contact.updateMany({
|
||
where: {
|
||
remoteJid: contact.remoteJid,
|
||
instanceId: this.instanceId,
|
||
},
|
||
data: { profilePicUrl: contact.profilePicUrl },
|
||
});
|
||
|
||
if (
|
||
this.configService.get<Chatwoot>("CHATWOOT").ENABLED &&
|
||
this.localChatwoot?.enabled
|
||
) {
|
||
const instance = {
|
||
instanceName: this.instance.name,
|
||
instanceId: this.instance.id,
|
||
};
|
||
|
||
const findParticipant = await this.chatwootService.findContact(
|
||
instance,
|
||
contact.remoteJid.split("@")[0]
|
||
);
|
||
|
||
if (!findParticipant) {
|
||
return;
|
||
}
|
||
|
||
this.chatwootService.updateContact(
|
||
instance,
|
||
findParticipant.id,
|
||
{
|
||
name: contact.pushName,
|
||
avatar_url: contact.profilePicUrl,
|
||
}
|
||
);
|
||
}
|
||
|
||
return update;
|
||
})
|
||
);
|
||
}
|
||
} catch (error) {
|
||
console.error(error);
|
||
this.logger.error(`Error: ${error.message}`);
|
||
}
|
||
},
|
||
|
||
"contacts.update": async (contacts: Partial<Contact>[]) => {
|
||
const contactsRaw: {
|
||
remoteJid: string;
|
||
pushName?: string;
|
||
profilePicUrl?: string;
|
||
instanceId: string;
|
||
}[] = [];
|
||
for await (const contact of contacts) {
|
||
this.logger.debug(
|
||
`Updating contact: ${JSON.stringify(contact, null, 2)}`
|
||
);
|
||
contactsRaw.push({
|
||
remoteJid: contact.id,
|
||
pushName: contact?.name ?? contact?.verifiedName,
|
||
profilePicUrl: (await this.profilePicture(contact.id))
|
||
.profilePictureUrl,
|
||
instanceId: this.instanceId,
|
||
});
|
||
}
|
||
|
||
this.sendDataWebhook(Events.CONTACTS_UPDATE, contactsRaw);
|
||
|
||
const updateTransactions = contactsRaw.map((contact) =>
|
||
this.prismaRepository.contact.upsert({
|
||
where: {
|
||
remoteJid_instanceId: {
|
||
remoteJid: contact.remoteJid,
|
||
instanceId: contact.instanceId,
|
||
},
|
||
},
|
||
create: contact,
|
||
update: contact,
|
||
})
|
||
);
|
||
await this.prismaRepository.$transaction(updateTransactions);
|
||
|
||
//const usersContacts = contactsRaw.filter((c) => c.remoteJid.includes('@s.whatsapp'));
|
||
},
|
||
};
|
||
|
||
private readonly messageHandle = {
|
||
"messaging-history.set": async ({
|
||
messages,
|
||
chats,
|
||
contacts,
|
||
isLatest,
|
||
progress,
|
||
syncType,
|
||
}: {
|
||
chats: Chat[];
|
||
contacts: Contact[];
|
||
messages: WAMessage[];
|
||
isLatest?: boolean;
|
||
progress?: number;
|
||
syncType?: proto.HistorySync.HistorySyncType;
|
||
}) => {
|
||
try {
|
||
if (syncType === proto.HistorySync.HistorySyncType.ON_DEMAND) {
|
||
console.log("received on-demand history sync, messages=", messages);
|
||
}
|
||
console.log(
|
||
`recv ${chats.length} chats, ${contacts.length} contacts, ${messages.length} msgs (is latest: ${isLatest}, progress: ${progress}%), type: ${syncType}`
|
||
);
|
||
|
||
const instance: InstanceDto = { instanceName: this.instance.name };
|
||
|
||
let timestampLimitToImport = null;
|
||
|
||
if (this.configService.get<Chatwoot>("CHATWOOT").ENABLED) {
|
||
const daysLimitToImport = this.localChatwoot?.enabled
|
||
? this.localChatwoot.daysLimitImportMessages
|
||
: 1000;
|
||
|
||
const date = new Date();
|
||
timestampLimitToImport =
|
||
new Date(
|
||
date.setDate(date.getDate() - daysLimitToImport)
|
||
).getTime() / 1000;
|
||
|
||
const maxBatchTimestamp = Math.max(
|
||
...messages.map((message) => message.messageTimestamp as number)
|
||
);
|
||
|
||
const processBatch = maxBatchTimestamp >= timestampLimitToImport;
|
||
|
||
if (!processBatch) {
|
||
return;
|
||
}
|
||
}
|
||
|
||
const contactsMap = new Map();
|
||
|
||
for (const contact of contacts) {
|
||
if (contact.id && (contact.notify || contact.name)) {
|
||
contactsMap.set(contact.id, {
|
||
name: contact.name ?? contact.notify,
|
||
jid: contact.id,
|
||
});
|
||
}
|
||
}
|
||
|
||
const chatsRaw: {
|
||
remoteJid: string;
|
||
instanceId: string;
|
||
name?: string;
|
||
}[] = [];
|
||
const chatsRepository = new Set(
|
||
(
|
||
await this.prismaRepository.chat.findMany({
|
||
where: { instanceId: this.instanceId },
|
||
})
|
||
).map((chat) => chat.remoteJid)
|
||
);
|
||
|
||
for (const chat of chats) {
|
||
if (chatsRepository?.has(chat.id)) {
|
||
continue;
|
||
}
|
||
|
||
chatsRaw.push({
|
||
remoteJid: chat.id,
|
||
instanceId: this.instanceId,
|
||
name: chat.name,
|
||
});
|
||
}
|
||
|
||
this.sendDataWebhook(Events.CHATS_SET, chatsRaw);
|
||
|
||
if (this.configService.get<Database>("DATABASE").SAVE_DATA.HISTORIC) {
|
||
await this.prismaRepository.chat.createMany({
|
||
data: chatsRaw,
|
||
skipDuplicates: true,
|
||
});
|
||
}
|
||
|
||
const messagesRaw: any[] = [];
|
||
|
||
const messagesRepository: Set<string> = new Set(
|
||
chatwootImport.getRepositoryMessagesCache(instance) ??
|
||
(
|
||
await this.prismaRepository.message.findMany({
|
||
select: { key: true },
|
||
where: { instanceId: this.instanceId },
|
||
})
|
||
).map((message) => {
|
||
const key = message.key as { id: string };
|
||
|
||
return key.id;
|
||
})
|
||
);
|
||
|
||
if (chatwootImport.getRepositoryMessagesCache(instance) === null) {
|
||
chatwootImport.setRepositoryMessagesCache(
|
||
instance,
|
||
messagesRepository
|
||
);
|
||
}
|
||
|
||
for (const m of messages) {
|
||
if (!m.message || !m.key || !m.messageTimestamp) {
|
||
continue;
|
||
}
|
||
|
||
if (Long.isLong(m?.messageTimestamp)) {
|
||
m.messageTimestamp = m.messageTimestamp?.toNumber();
|
||
}
|
||
|
||
if (this.configService.get<Chatwoot>("CHATWOOT").ENABLED) {
|
||
if (m.messageTimestamp <= timestampLimitToImport) {
|
||
continue;
|
||
}
|
||
}
|
||
|
||
if (messagesRepository?.has(m.key.id)) {
|
||
continue;
|
||
}
|
||
|
||
if (!m.pushName && !m.key.fromMe) {
|
||
const participantJid =
|
||
m.participant || m.key.participant || m.key.remoteJid;
|
||
if (participantJid && contactsMap.has(participantJid)) {
|
||
m.pushName = contactsMap.get(participantJid).name;
|
||
} else if (participantJid) {
|
||
m.pushName = participantJid.split("@")[0];
|
||
}
|
||
}
|
||
|
||
messagesRaw.push(this.prepareMessage(m));
|
||
}
|
||
|
||
this.sendDataWebhook(Events.MESSAGES_SET, [...messagesRaw]);
|
||
|
||
if (this.configService.get<Database>("DATABASE").SAVE_DATA.HISTORIC) {
|
||
await this.prismaRepository.message.createMany({
|
||
data: messagesRaw,
|
||
skipDuplicates: true,
|
||
});
|
||
}
|
||
|
||
if (
|
||
this.configService.get<Chatwoot>("CHATWOOT").ENABLED &&
|
||
this.localChatwoot?.enabled &&
|
||
this.localChatwoot.importMessages &&
|
||
messagesRaw.length > 0
|
||
) {
|
||
this.chatwootService.addHistoryMessages(
|
||
instance,
|
||
messagesRaw.filter(
|
||
(msg) => !chatwootImport.isIgnorePhoneNumber(msg.key?.remoteJid)
|
||
)
|
||
);
|
||
}
|
||
|
||
await this.contactHandle["contacts.upsert"](
|
||
contacts
|
||
.filter((c) => !!c.notify || !!c.name)
|
||
.map((c) => ({ id: c.id, name: c.name ?? c.notify }))
|
||
);
|
||
|
||
contacts = undefined;
|
||
messages = undefined;
|
||
chats = undefined;
|
||
} catch (error) {
|
||
this.logger.error(error);
|
||
}
|
||
},
|
||
|
||
"messages.upsert": async (
|
||
{
|
||
messages,
|
||
type,
|
||
requestId,
|
||
}: { messages: WAMessage[]; type: MessageUpsertType; requestId?: string },
|
||
settings: any
|
||
) => {
|
||
try {
|
||
for (const received of messages) {
|
||
if (
|
||
received?.messageStubParameters?.some?.((param) =>
|
||
[
|
||
"No matching sessions found for message",
|
||
"Bad MAC",
|
||
"failed to decrypt message",
|
||
"SessionError",
|
||
"Invalid PreKey ID",
|
||
"No session record",
|
||
"No session found to decrypt message",
|
||
].some((err) => param?.includes?.(err))
|
||
)
|
||
) {
|
||
this.logger.warn(
|
||
`Message ignored with messageStubParameters: ${JSON.stringify(
|
||
received,
|
||
null,
|
||
2
|
||
)}`
|
||
);
|
||
continue;
|
||
}
|
||
if (
|
||
received.message?.conversation ||
|
||
received.message?.extendedTextMessage?.text
|
||
) {
|
||
const text =
|
||
received.message?.conversation ||
|
||
received.message?.extendedTextMessage?.text;
|
||
|
||
if (text == "requestPlaceholder" && !requestId) {
|
||
const messageId = await this.client.requestPlaceholderResend(
|
||
received.key
|
||
);
|
||
|
||
console.log("requested placeholder resync, id=", messageId);
|
||
} else if (requestId) {
|
||
console.log(
|
||
"Message received from phone, id=",
|
||
requestId,
|
||
received
|
||
);
|
||
}
|
||
|
||
if (text == "onDemandHistSync") {
|
||
const messageId = await this.client.fetchMessageHistory(
|
||
50,
|
||
received.key,
|
||
received.messageTimestamp!
|
||
);
|
||
console.log("requested on-demand sync, id=", messageId);
|
||
}
|
||
}
|
||
// EDIT/DELETE (Baileys) sem duplicação e sem texto no WhatsApp
|
||
const protocolMsg: any =
|
||
received?.message?.protocolMessage ||
|
||
received?.message?.editedMessage?.message?.protocolMessage;
|
||
|
||
const isStubRevoke =
|
||
received?.messageStubType === proto.WebMessageInfo.StubType.REVOKE;
|
||
|
||
// DELETE (somente via StubType)
|
||
if (isStubRevoke) {
|
||
const keyToSearch: any = received?.key ?? {};
|
||
const status = 'DELETED';
|
||
const webhookEvent = Events.MESSAGES_DELETE;
|
||
|
||
// anti-dup (absorve REVOKE múltiplo: stub + outras origens)
|
||
const delKey = `cw_del_${this.instance.id}_${keyToSearch?.id}`;
|
||
const seen = await this.baileysCache.get(delKey);
|
||
if (seen) {
|
||
this.logger.info(`Skip duplicate delete for ${keyToSearch?.id}`);
|
||
continue;
|
||
}
|
||
await this.baileysCache.set(delKey, true, 15);
|
||
|
||
// payload limpo para DB; WhatsApp NÃO recebe placeholder
|
||
const messageContentUpdate = { conversation: '' };
|
||
|
||
// Chatwoot: manda o placeholder
|
||
if (
|
||
this.configService.get<Chatwoot>('CHATWOOT').ENABLED &&
|
||
this.localChatwoot?.enabled
|
||
) {
|
||
const chatwootPayload: any = {
|
||
...received,
|
||
key: keyToSearch,
|
||
text: '🗑️ O remetente apagou uma mensagem.',
|
||
};
|
||
|
||
await this.chatwootService.eventWhatsapp(
|
||
Events.MESSAGES_DELETE,
|
||
{ instanceName: this.instance.name, instanceId: this.instance.id },
|
||
chatwootPayload
|
||
);
|
||
}
|
||
|
||
// Webhook externo
|
||
await this.sendDataWebhook(webhookEvent, received);
|
||
|
||
// Persistência local
|
||
const oldMessage = await this.getMessage(keyToSearch, true);
|
||
if ((oldMessage as any)?.id) {
|
||
const editedMessageTimestamp = Long.isLong(received?.messageTimestamp)
|
||
? Math.floor((received?.messageTimestamp as Long).toNumber())
|
||
: Math.floor((received?.messageTimestamp as number) ?? Date.now() / 1000);
|
||
|
||
await this.prismaRepository.message.update({
|
||
where: { id: (oldMessage as any).id },
|
||
data: {
|
||
message: messageContentUpdate,
|
||
messageTimestamp: editedMessageTimestamp,
|
||
status,
|
||
},
|
||
});
|
||
|
||
await this.prismaRepository.messageUpdate.create({
|
||
data: {
|
||
fromMe: keyToSearch?.fromMe ?? false,
|
||
keyId: keyToSearch?.id ?? '',
|
||
remoteJid: keyToSearch?.remoteJid ?? '',
|
||
status,
|
||
instanceId: this.instanceId,
|
||
messageId: (oldMessage as any).id,
|
||
},
|
||
});
|
||
}
|
||
|
||
continue; // não cai no MESSAGES_UPSERT
|
||
}
|
||
|
||
// EDIT (somente quando NÃO for REVOKE)
|
||
if (protocolMsg && protocolMsg.type !== proto.Message.ProtocolMessage.Type.REVOKE) {
|
||
const payloadToProcess: any = protocolMsg;
|
||
const keyToSearch: any = payloadToProcess?.key ?? received?.key;
|
||
|
||
// conteúdo de edição (WhatsApp não recebe placeholder)
|
||
const messageContentUpdate: any =
|
||
(payloadToProcess as any)?.editedMessage || { conversation: '' };
|
||
const status = 'EDITED';
|
||
const webhookEvent = Events.MESSAGES_EDITED;
|
||
|
||
// Chatwoot: envia texto editado
|
||
if (
|
||
this.configService.get<Chatwoot>('CHATWOOT').ENABLED &&
|
||
this.localChatwoot?.enabled
|
||
) {
|
||
const chatwootPayload: any = {
|
||
...payloadToProcess,
|
||
key: keyToSearch,
|
||
editedMessage:
|
||
(payloadToProcess as any)?.editedMessage ?? messageContentUpdate,
|
||
};
|
||
|
||
const friendlyText =
|
||
messageContentUpdate?.conversation ??
|
||
messageContentUpdate?.extendedTextMessage?.text ??
|
||
messageContentUpdate?.imageMessage?.caption ??
|
||
messageContentUpdate?.videoMessage?.caption ??
|
||
messageContentUpdate?.documentMessage?.caption ??
|
||
(payloadToProcess as any)?.text ??
|
||
'';
|
||
|
||
if (friendlyText) {
|
||
chatwootPayload.text = friendlyText;
|
||
}
|
||
|
||
await this.chatwootService.eventWhatsapp(
|
||
'messages.edit',
|
||
{ instanceName: this.instance.name, instanceId: this.instance.id },
|
||
chatwootPayload
|
||
);
|
||
}
|
||
|
||
// Webhook externo
|
||
await this.sendDataWebhook(webhookEvent, payloadToProcess);
|
||
|
||
// Persistência local
|
||
const oldMessage = await this.getMessage(keyToSearch, true);
|
||
if ((oldMessage as any)?.id) {
|
||
const editedMessageTimestamp = Long.isLong(received?.messageTimestamp)
|
||
? Math.floor((received?.messageTimestamp as Long).toNumber())
|
||
: Math.floor((received?.messageTimestamp as number) ?? Date.now() / 1000);
|
||
|
||
await this.prismaRepository.message.update({
|
||
where: { id: (oldMessage as any).id },
|
||
data: {
|
||
message: messageContentUpdate,
|
||
messageTimestamp: editedMessageTimestamp,
|
||
status,
|
||
},
|
||
});
|
||
|
||
await this.prismaRepository.messageUpdate.create({
|
||
data: {
|
||
fromMe: keyToSearch?.fromMe ?? false,
|
||
keyId: keyToSearch?.id ?? '',
|
||
remoteJid: keyToSearch?.remoteJid ?? '',
|
||
status,
|
||
instanceId: this.instanceId,
|
||
messageId: (oldMessage as any).id,
|
||
},
|
||
});
|
||
}
|
||
|
||
continue; // não cai no MESSAGES_UPSERT
|
||
}
|
||
// FIM EDIT/DELETE (Baileys)
|
||
// flag local só pra deduplicar nesse bloco normal de upsert
|
||
const isEditOrDelete = !!protocolMsg || (received?.messageStubType === proto.WebMessageInfo.StubType.REVOKE);
|
||
|
||
const messageKey = `${this.instance.id}_${received.key.id}`;
|
||
const cached = await this.baileysCache.get(messageKey);
|
||
|
||
// antes: if (cached && !editedMessage && !requestId) {
|
||
if (cached && !isEditOrDelete && !requestId) {
|
||
this.logger.info(`Message duplicated ignored: ${received.key.id}`);
|
||
continue;
|
||
}
|
||
//await this.baileysCache.set(messageKey, true, this.MESSAGE_CACHE_TTL_SECONDS); comentei aqui by rafael
|
||
if (
|
||
(type !== "notify" && type !== "append") ||
|
||
// antes: editedMessage ||
|
||
isEditOrDelete ||
|
||
received.message?.pollUpdateMessage ||
|
||
!received?.message
|
||
) {
|
||
continue;
|
||
}
|
||
// lock otimista contra duplicação na 1ª mensagem da conversa
|
||
const lockKey = `lock_${messageKey}`;
|
||
const hasLock = await this.baileysCache.get(lockKey);
|
||
if (hasLock) {
|
||
this.logger.info(`(lock) duplicated start ignored: ${received.key.id}`);
|
||
continue;
|
||
}
|
||
// marca lock com TTL curto (15–20s é suficiente)
|
||
await this.baileysCache.set(lockKey, true, 20);
|
||
|
||
|
||
if (Long.isLong(received.messageTimestamp)) {
|
||
received.messageTimestamp = received.messageTimestamp?.toNumber();
|
||
}
|
||
|
||
if (
|
||
settings?.groupsIgnore &&
|
||
received.key.remoteJid.includes("@g.us")
|
||
) {
|
||
continue;
|
||
}
|
||
|
||
const existingChat = await this.prismaRepository.chat.findFirst({
|
||
where: {
|
||
instanceId: this.instanceId,
|
||
remoteJid: received.key.remoteJid,
|
||
},
|
||
select: { id: true, name: true },
|
||
});
|
||
|
||
if (
|
||
existingChat &&
|
||
received.pushName &&
|
||
existingChat.name !== received.pushName &&
|
||
received.pushName.trim().length > 0 &&
|
||
!received.key.fromMe &&
|
||
!received.key.remoteJid.includes("@g.us")
|
||
) {
|
||
this.sendDataWebhook(Events.CHATS_UPSERT, [
|
||
{ ...existingChat, name: received.pushName },
|
||
]);
|
||
if (this.configService.get<Database>("DATABASE").SAVE_DATA.CHATS) {
|
||
try {
|
||
await this.prismaRepository.chat.update({
|
||
where: { id: existingChat.id },
|
||
data: { name: received.pushName },
|
||
});
|
||
} catch {
|
||
console.log(
|
||
`Chat insert record ignored: ${received.key.remoteJid} - ${this.instanceId}`
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
const messageRaw = this.prepareMessage(received);
|
||
|
||
const isMedia =
|
||
received?.message?.imageMessage ||
|
||
received?.message?.videoMessage ||
|
||
received?.message?.stickerMessage ||
|
||
received?.message?.documentMessage ||
|
||
received?.message?.documentWithCaptionMessage ||
|
||
received?.message?.ptvMessage ||
|
||
received?.message?.audioMessage;
|
||
|
||
const isVideo = received?.message?.videoMessage;
|
||
|
||
if (
|
||
this.localSettings.readMessages &&
|
||
received.key.id !== "status@broadcast"
|
||
) {
|
||
await this.client.readMessages([received.key]);
|
||
}
|
||
|
||
if (
|
||
this.localSettings.readStatus &&
|
||
received.key.id === "status@broadcast"
|
||
) {
|
||
await this.client.readMessages([received.key]);
|
||
}
|
||
|
||
if (
|
||
this.configService.get<Chatwoot>("CHATWOOT").ENABLED &&
|
||
this.localChatwoot?.enabled &&
|
||
!received.key.id.includes("@broadcast")
|
||
) {
|
||
const chatwootSentMessage =
|
||
await this.chatwootService.eventWhatsapp(
|
||
Events.MESSAGES_UPSERT,
|
||
{
|
||
instanceName: this.instance.name,
|
||
instanceId: this.instanceId,
|
||
},
|
||
messageRaw
|
||
);
|
||
|
||
if (chatwootSentMessage?.id) {
|
||
messageRaw.chatwootMessageId = chatwootSentMessage.id;
|
||
messageRaw.chatwootInboxId = chatwootSentMessage.inbox_id;
|
||
messageRaw.chatwootConversationId =
|
||
chatwootSentMessage.conversation_id;
|
||
|
||
// evita duplicação após o Chatwoot responder
|
||
await this.baileysCache.set(
|
||
messageKey,
|
||
true,
|
||
this.MESSAGE_CACHE_TTL_SECONDS
|
||
);
|
||
}
|
||
|
||
} else {
|
||
// Chatwoot desativado → ainda assim evita duplicação
|
||
await this.baileysCache.set(
|
||
messageKey,
|
||
true,
|
||
this.MESSAGE_CACHE_TTL_SECONDS
|
||
);
|
||
}
|
||
|
||
if (
|
||
this.configService.get<Openai>("OPENAI").ENABLED &&
|
||
received?.message?.audioMessage
|
||
) {
|
||
const openAiDefaultSettings =
|
||
await this.prismaRepository.openaiSetting.findFirst({
|
||
where: { instanceId: this.instanceId },
|
||
include: { OpenaiCreds: true },
|
||
});
|
||
|
||
if (
|
||
openAiDefaultSettings &&
|
||
openAiDefaultSettings.openaiCredsId &&
|
||
openAiDefaultSettings.speechToText
|
||
) {
|
||
messageRaw.message.speechToText = `[audio] ${await this.openaiService.speechToText(
|
||
received,
|
||
this
|
||
)}`;
|
||
}
|
||
}
|
||
|
||
if (
|
||
this.configService.get<Database>("DATABASE").SAVE_DATA.NEW_MESSAGE
|
||
) {
|
||
const msg = await this.prismaRepository.message.create({
|
||
data: messageRaw,
|
||
});
|
||
|
||
const { remoteJid } = received.key;
|
||
const timestamp = msg.messageTimestamp;
|
||
const fromMe = received.key.fromMe.toString();
|
||
const messageKey = `${remoteJid}_${timestamp}_${fromMe}`;
|
||
|
||
const cachedTimestamp = await this.baileysCache.get(messageKey);
|
||
|
||
if (!cachedTimestamp) {
|
||
if (!received.key.fromMe) {
|
||
if (msg.status === status[3]) {
|
||
this.logger.log(`Update not read messages ${remoteJid}`);
|
||
await this.updateChatUnreadMessages(remoteJid);
|
||
} else if (msg.status === status[4]) {
|
||
this.logger.log(
|
||
`Update readed messages ${remoteJid} - ${timestamp}`
|
||
);
|
||
await this.updateMessagesReadedByTimestamp(
|
||
remoteJid,
|
||
timestamp
|
||
);
|
||
}
|
||
} else {
|
||
// is send message by me
|
||
this.logger.log(
|
||
`Update readed messages ${remoteJid} - ${timestamp}`
|
||
);
|
||
await this.updateMessagesReadedByTimestamp(
|
||
remoteJid,
|
||
timestamp
|
||
);
|
||
}
|
||
|
||
await this.baileysCache.set(
|
||
messageKey,
|
||
true,
|
||
this.MESSAGE_CACHE_TTL_SECONDS
|
||
);
|
||
} else {
|
||
this.logger.info(
|
||
`Update readed messages duplicated ignored [avoid deadlock]: ${messageKey}`
|
||
);
|
||
}
|
||
|
||
if (isMedia) {
|
||
if (this.configService.get<S3>("S3").ENABLE) {
|
||
try {
|
||
if (isVideo && !this.configService.get<S3>("S3").SAVE_VIDEO) {
|
||
this.logger.warn(
|
||
"Video upload is disabled. Skipping video upload."
|
||
);
|
||
// Skip video upload by returning early from this block
|
||
return;
|
||
}
|
||
|
||
const message: any = received;
|
||
|
||
// Verificação adicional para garantir que há conteúdo de mídia real
|
||
const hasRealMedia = this.hasValidMediaContent(message);
|
||
|
||
if (!hasRealMedia) {
|
||
this.logger.warn(
|
||
"Message detected as media but contains no valid media content"
|
||
);
|
||
} else {
|
||
const media = await this.getBase64FromMediaMessage(
|
||
{ message },
|
||
true
|
||
);
|
||
|
||
const { buffer, mediaType, fileName, size } = media;
|
||
const mimetype = mimeTypes.lookup(fileName).toString();
|
||
const fullName = join(
|
||
`${this.instance.id}`,
|
||
received.key.remoteJid,
|
||
mediaType,
|
||
`${Date.now()}_${fileName}`
|
||
);
|
||
await s3Service.uploadFile(
|
||
fullName,
|
||
buffer,
|
||
size.fileLength?.low,
|
||
{ "Content-Type": mimetype }
|
||
);
|
||
|
||
await this.prismaRepository.media.create({
|
||
data: {
|
||
messageId: msg.id,
|
||
instanceId: this.instanceId,
|
||
type: mediaType,
|
||
fileName: fullName,
|
||
mimetype,
|
||
},
|
||
});
|
||
|
||
const mediaUrl = await s3Service.getObjectUrl(fullName);
|
||
|
||
messageRaw.message.mediaUrl = mediaUrl;
|
||
|
||
await this.prismaRepository.message.update({
|
||
where: { id: msg.id },
|
||
data: messageRaw,
|
||
});
|
||
}
|
||
} catch (error) {
|
||
this.logger.error([
|
||
"Error on upload file to minio",
|
||
error?.message,
|
||
error?.stack,
|
||
]);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if (this.localWebhook.enabled) {
|
||
if (isMedia && this.localWebhook.webhookBase64) {
|
||
try {
|
||
const buffer = await downloadMediaMessage(
|
||
{ key: received.key, message: received?.message },
|
||
"buffer",
|
||
{},
|
||
{
|
||
logger: P({ level: "error" }) as any,
|
||
reuploadRequest: this.client.updateMediaMessage,
|
||
}
|
||
);
|
||
|
||
if (buffer) {
|
||
messageRaw.message.base64 = buffer.toString("base64");
|
||
} else {
|
||
// retry to download media
|
||
const buffer = await downloadMediaMessage(
|
||
{ key: received.key, message: received?.message },
|
||
"buffer",
|
||
{},
|
||
{
|
||
logger: P({ level: "error" }) as any,
|
||
reuploadRequest: this.client.updateMediaMessage,
|
||
}
|
||
);
|
||
|
||
if (buffer) {
|
||
messageRaw.message.base64 = buffer.toString("base64");
|
||
}
|
||
}
|
||
} catch (error) {
|
||
this.logger.error([
|
||
"Error converting media to base64",
|
||
error?.message,
|
||
]);
|
||
}
|
||
}
|
||
}
|
||
|
||
this.logger.verbose(messageRaw);
|
||
|
||
sendTelemetry(
|
||
`received.message.${messageRaw.messageType ?? "unknown"}`
|
||
);
|
||
|
||
this.sendDataWebhook(Events.MESSAGES_UPSERT, messageRaw);
|
||
|
||
await chatbotController.emit({
|
||
instance: {
|
||
instanceName: this.instance.name,
|
||
instanceId: this.instanceId,
|
||
},
|
||
remoteJid: messageRaw.key.remoteJid,
|
||
msg: messageRaw,
|
||
pushName: messageRaw.pushName,
|
||
});
|
||
|
||
const contact = await this.prismaRepository.contact.findFirst({
|
||
where: {
|
||
remoteJid: received.key.remoteJid,
|
||
instanceId: this.instanceId,
|
||
},
|
||
});
|
||
|
||
const contactRaw: {
|
||
remoteJid: string;
|
||
pushName: string;
|
||
profilePicUrl?: string;
|
||
instanceId: string;
|
||
} = {
|
||
remoteJid: received.key.remoteJid,
|
||
pushName: received.key.fromMe
|
||
? ""
|
||
: received.key.fromMe == null
|
||
? ""
|
||
: received.pushName,
|
||
profilePicUrl: (await this.profilePicture(received.key.remoteJid))
|
||
.profilePictureUrl,
|
||
instanceId: this.instanceId,
|
||
};
|
||
|
||
if (contactRaw.remoteJid === "status@broadcast") {
|
||
continue;
|
||
}
|
||
|
||
if (
|
||
contactRaw.remoteJid.includes("@s.whatsapp") ||
|
||
contactRaw.remoteJid.includes("@lid")
|
||
) {
|
||
await saveOnWhatsappCache([
|
||
{
|
||
remoteJid:
|
||
messageRaw.key.addressingMode === "lid"
|
||
? messageRaw.key.remoteJidAlt
|
||
: messageRaw.key.remoteJid,
|
||
remoteJidAlt: messageRaw.key.remoteJidAlt,
|
||
lid: messageRaw.key.addressingMode === "lid" ? "lid" : null,
|
||
},
|
||
]);
|
||
}
|
||
|
||
if (contact) {
|
||
this.sendDataWebhook(Events.CONTACTS_UPDATE, contactRaw);
|
||
|
||
if (
|
||
this.configService.get<Chatwoot>("CHATWOOT").ENABLED &&
|
||
this.localChatwoot?.enabled
|
||
) {
|
||
await this.chatwootService.eventWhatsapp(
|
||
Events.CONTACTS_UPDATE,
|
||
{
|
||
instanceName: this.instance.name,
|
||
instanceId: this.instanceId,
|
||
},
|
||
contactRaw
|
||
);
|
||
}
|
||
|
||
if (this.configService.get<Database>("DATABASE").SAVE_DATA.CONTACTS)
|
||
await this.prismaRepository.contact.upsert({
|
||
where: {
|
||
remoteJid_instanceId: {
|
||
remoteJid: contactRaw.remoteJid,
|
||
instanceId: contactRaw.instanceId,
|
||
},
|
||
},
|
||
create: contactRaw,
|
||
update: contactRaw,
|
||
});
|
||
|
||
continue;
|
||
}
|
||
|
||
this.sendDataWebhook(Events.CONTACTS_UPSERT, contactRaw);
|
||
|
||
if (this.configService.get<Database>("DATABASE").SAVE_DATA.CONTACTS)
|
||
await this.prismaRepository.contact.upsert({
|
||
where: {
|
||
remoteJid_instanceId: {
|
||
remoteJid: contactRaw.remoteJid,
|
||
instanceId: contactRaw.instanceId,
|
||
},
|
||
},
|
||
update: contactRaw,
|
||
create: contactRaw,
|
||
});
|
||
}
|
||
} catch (error) {
|
||
this.logger.error(error);
|
||
}
|
||
},
|
||
|
||
"messages.update": async (
|
||
args: { update: Partial<WAMessage>; key: WAMessageKey }[],
|
||
settings: any
|
||
) => {
|
||
this.logger.verbose(
|
||
`Update messages ${JSON.stringify(args, undefined, 2)}`
|
||
);
|
||
|
||
const readChatToUpdate: Record<string, true> = {}; // {remoteJid: true}
|
||
|
||
for await (const { key, update } of args) {
|
||
if (settings?.groupsIgnore && key.remoteJid?.includes("@g.us")) {
|
||
continue;
|
||
}
|
||
|
||
if (update.message !== null && update.status === undefined) continue;
|
||
|
||
const updateKey = `${this.instance.id}_${key.id}_${update.status}`;
|
||
|
||
const cached = await this.baileysCache.get(updateKey);
|
||
|
||
if (cached) {
|
||
this.logger.info(
|
||
`Message duplicated ignored [avoid deadlock]: ${updateKey}`
|
||
);
|
||
continue;
|
||
}
|
||
|
||
await this.baileysCache.set(updateKey, true, 30 * 60);
|
||
|
||
if (status[update.status] === "READ" && key.fromMe) {
|
||
if (
|
||
this.configService.get<Chatwoot>("CHATWOOT").ENABLED &&
|
||
this.localChatwoot?.enabled
|
||
) {
|
||
this.chatwootService.eventWhatsapp(
|
||
"messages.read",
|
||
{ instanceName: this.instance.name, instanceId: this.instanceId },
|
||
{ key: key }
|
||
);
|
||
}
|
||
}
|
||
|
||
if (key.remoteJid !== "status@broadcast" && key.id !== undefined) {
|
||
let pollUpdates: any;
|
||
|
||
if (update.pollUpdates) {
|
||
const pollCreation = await this.getMessage(key);
|
||
|
||
if (pollCreation) {
|
||
pollUpdates = getAggregateVotesInPollMessage({
|
||
message: pollCreation as proto.IMessage,
|
||
pollUpdates: update.pollUpdates,
|
||
});
|
||
}
|
||
}
|
||
|
||
const message: any = {
|
||
keyId: key.id,
|
||
remoteJid: key?.remoteJid,
|
||
fromMe: key.fromMe,
|
||
participant: key?.participant,
|
||
status: status[update.status] ?? "DELETED",
|
||
pollUpdates,
|
||
instanceId: this.instanceId,
|
||
};
|
||
|
||
let findMessage: any;
|
||
const configDatabaseData =
|
||
this.configService.get<Database>("DATABASE").SAVE_DATA;
|
||
if (configDatabaseData.HISTORIC || configDatabaseData.NEW_MESSAGE) {
|
||
// Use raw SQL to avoid JSON path issues
|
||
const messages = (await this.prismaRepository.$queryRaw`
|
||
SELECT * FROM "Message"
|
||
WHERE "instanceId" = ${this.instanceId}
|
||
AND "key"->>'id' = ${key.id}
|
||
LIMIT 1
|
||
`) as any[];
|
||
findMessage = messages[0] || null;
|
||
|
||
if (!findMessage?.id) {
|
||
this.logger.warn(
|
||
`Original message not found for update. Skipping. Key: ${JSON.stringify(
|
||
key
|
||
)}`
|
||
);
|
||
continue;
|
||
}
|
||
message.messageId = findMessage.id;
|
||
}
|
||
|
||
if (update.message === null && update.status === undefined) {
|
||
this.sendDataWebhook(Events.MESSAGES_DELETE, key);
|
||
|
||
if (
|
||
this.configService.get<Database>("DATABASE").SAVE_DATA
|
||
.MESSAGE_UPDATE
|
||
)
|
||
await this.prismaRepository.messageUpdate.create({
|
||
data: message,
|
||
});
|
||
|
||
if (
|
||
this.configService.get<Chatwoot>("CHATWOOT").ENABLED &&
|
||
this.localChatwoot?.enabled
|
||
) {
|
||
this.chatwootService.eventWhatsapp(
|
||
Events.MESSAGES_DELETE,
|
||
{
|
||
instanceName: this.instance.name,
|
||
instanceId: this.instanceId,
|
||
},
|
||
{ key: key }
|
||
);
|
||
}
|
||
|
||
continue;
|
||
}
|
||
|
||
if (
|
||
findMessage &&
|
||
update.status !== undefined &&
|
||
status[update.status] !== findMessage.status
|
||
) {
|
||
if (!key.fromMe && key.remoteJid) {
|
||
readChatToUpdate[key.remoteJid] = true;
|
||
|
||
const { remoteJid } = key;
|
||
const timestamp = findMessage.messageTimestamp;
|
||
const fromMe = key.fromMe.toString();
|
||
const messageKey = `${remoteJid}_${timestamp}_${fromMe}`;
|
||
|
||
const cachedTimestamp = await this.baileysCache.get(messageKey);
|
||
|
||
if (!cachedTimestamp) {
|
||
if (status[update.status] === status[4]) {
|
||
this.logger.log(
|
||
`Update as read in message.update ${remoteJid} - ${timestamp}`
|
||
);
|
||
await this.updateMessagesReadedByTimestamp(
|
||
remoteJid,
|
||
timestamp
|
||
);
|
||
await this.baileysCache.set(
|
||
messageKey,
|
||
true,
|
||
this.MESSAGE_CACHE_TTL_SECONDS
|
||
);
|
||
}
|
||
|
||
await this.prismaRepository.message.update({
|
||
where: { id: findMessage.id },
|
||
data: { status: status[update.status] },
|
||
});
|
||
} else {
|
||
this.logger.info(
|
||
`Update readed messages duplicated ignored in message.update [avoid deadlock]: ${messageKey}`
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
this.sendDataWebhook(Events.MESSAGES_UPDATE, message);
|
||
|
||
if (
|
||
this.configService.get<Database>("DATABASE").SAVE_DATA
|
||
.MESSAGE_UPDATE
|
||
)
|
||
await this.prismaRepository.messageUpdate.create({ data: message });
|
||
|
||
const existingChat = await this.prismaRepository.chat.findFirst({
|
||
where: {
|
||
instanceId: this.instanceId,
|
||
remoteJid: message.remoteJid,
|
||
},
|
||
});
|
||
|
||
if (existingChat) {
|
||
const chatToInsert = {
|
||
remoteJid: message.remoteJid,
|
||
instanceId: this.instanceId,
|
||
unreadMessages: 0,
|
||
};
|
||
|
||
this.sendDataWebhook(Events.CHATS_UPSERT, [chatToInsert]);
|
||
if (this.configService.get<Database>("DATABASE").SAVE_DATA.CHATS) {
|
||
try {
|
||
await this.prismaRepository.chat.update({
|
||
where: { id: existingChat.id },
|
||
data: chatToInsert,
|
||
});
|
||
} catch {
|
||
console.log(
|
||
`Chat insert record ignored: ${chatToInsert.remoteJid} - ${chatToInsert.instanceId}`
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
await Promise.all(
|
||
Object.keys(readChatToUpdate).map((remoteJid) =>
|
||
this.updateChatUnreadMessages(remoteJid)
|
||
)
|
||
);
|
||
},
|
||
};
|
||
|
||
private readonly groupHandler = {
|
||
"groups.upsert": (groupMetadata: GroupMetadata[]) => {
|
||
this.sendDataWebhook(Events.GROUPS_UPSERT, groupMetadata);
|
||
},
|
||
|
||
"groups.update": (groupMetadataUpdate: Partial<GroupMetadata>[]) => {
|
||
this.sendDataWebhook(Events.GROUPS_UPDATE, groupMetadataUpdate);
|
||
|
||
groupMetadataUpdate.forEach((group) => {
|
||
if (isJidGroup(group.id)) {
|
||
this.updateGroupMetadataCache(group.id);
|
||
}
|
||
});
|
||
},
|
||
|
||
"group-participants.update": async (participantsUpdate: {
|
||
id: string;
|
||
participants: string[];
|
||
action: ParticipantAction;
|
||
}) => {
|
||
// ENHANCEMENT: Adds participantsData field while maintaining backward compatibility
|
||
// MAINTAINS: participants: string[] (original JID strings)
|
||
// ADDS: participantsData: { jid: string, phoneNumber: string, name?: string, imgUrl?: string }[]
|
||
// This enables LID to phoneNumber conversion without breaking existing webhook consumers
|
||
|
||
// Helper to normalize participantId as phone number
|
||
const normalizePhoneNumber = (id: string): string => {
|
||
// Remove @lid, @s.whatsapp.net suffixes and extract just the number part
|
||
return id.split("@")[0];
|
||
};
|
||
|
||
try {
|
||
// Usa o mesmo método que o endpoint /group/participants
|
||
const groupParticipants = await this.findParticipants({
|
||
groupJid: participantsUpdate.id,
|
||
});
|
||
|
||
// Validação para garantir que temos dados válidos
|
||
if (
|
||
!groupParticipants?.participants ||
|
||
!Array.isArray(groupParticipants.participants)
|
||
) {
|
||
throw new Error(
|
||
"Invalid participant data received from findParticipants"
|
||
);
|
||
}
|
||
|
||
// Filtra apenas os participantes que estão no evento
|
||
const resolvedParticipants = participantsUpdate.participants.map(
|
||
(participantId) => {
|
||
const participantData = groupParticipants.participants.find(
|
||
(p) => p.id === participantId
|
||
);
|
||
|
||
let phoneNumber: string;
|
||
if (participantData?.phoneNumber) {
|
||
phoneNumber = participantData.phoneNumber;
|
||
} else {
|
||
phoneNumber = normalizePhoneNumber(participantId);
|
||
}
|
||
|
||
return {
|
||
jid: participantId,
|
||
phoneNumber,
|
||
name: participantData?.name,
|
||
imgUrl: participantData?.imgUrl,
|
||
};
|
||
}
|
||
);
|
||
|
||
// Mantém formato original + adiciona dados resolvidos
|
||
const enhancedParticipantsUpdate = {
|
||
...participantsUpdate,
|
||
participants: participantsUpdate.participants, // Mantém array original de strings
|
||
// Adiciona dados resolvidos em campo separado
|
||
participantsData: resolvedParticipants,
|
||
};
|
||
|
||
this.sendDataWebhook(
|
||
Events.GROUP_PARTICIPANTS_UPDATE,
|
||
enhancedParticipantsUpdate
|
||
);
|
||
} catch (error) {
|
||
this.logger.error(
|
||
`Failed to resolve participant data for GROUP_PARTICIPANTS_UPDATE webhook: ${error.message} | Group: ${participantsUpdate.id} | Participants: ${participantsUpdate.participants.length}`
|
||
);
|
||
// Fallback - envia sem conversão
|
||
this.sendDataWebhook(
|
||
Events.GROUP_PARTICIPANTS_UPDATE,
|
||
participantsUpdate
|
||
);
|
||
}
|
||
|
||
this.updateGroupMetadataCache(participantsUpdate.id);
|
||
},
|
||
};
|
||
|
||
private readonly labelHandle = {
|
||
[Events.LABELS_EDIT]: async (label: Label) => {
|
||
this.sendDataWebhook(Events.LABELS_EDIT, {
|
||
...label,
|
||
instance: this.instance.name,
|
||
});
|
||
|
||
const labelsRepository = await this.prismaRepository.label.findMany({
|
||
where: { instanceId: this.instanceId },
|
||
});
|
||
|
||
const savedLabel = labelsRepository.find((l) => l.labelId === label.id);
|
||
if (label.deleted && savedLabel) {
|
||
await this.prismaRepository.label.delete({
|
||
where: {
|
||
labelId_instanceId: {
|
||
instanceId: this.instanceId,
|
||
labelId: label.id,
|
||
},
|
||
},
|
||
});
|
||
this.sendDataWebhook(Events.LABELS_EDIT, {
|
||
...label,
|
||
instance: this.instance.name,
|
||
});
|
||
return;
|
||
}
|
||
|
||
const labelName = label.name.replace(/[^\x20-\x7E]/g, "");
|
||
if (
|
||
!savedLabel ||
|
||
savedLabel.color !== `${label.color}` ||
|
||
savedLabel.name !== labelName
|
||
) {
|
||
if (this.configService.get<Database>("DATABASE").SAVE_DATA.LABELS) {
|
||
const labelData = {
|
||
color: `${label.color}`,
|
||
name: labelName,
|
||
labelId: label.id,
|
||
predefinedId: label.predefinedId,
|
||
instanceId: this.instanceId,
|
||
};
|
||
await this.prismaRepository.label.upsert({
|
||
where: {
|
||
labelId_instanceId: {
|
||
instanceId: labelData.instanceId,
|
||
labelId: labelData.labelId,
|
||
},
|
||
},
|
||
update: labelData,
|
||
create: labelData,
|
||
});
|
||
}
|
||
}
|
||
},
|
||
|
||
[Events.LABELS_ASSOCIATION]: async (
|
||
data: { association: LabelAssociation; type: "remove" | "add" },
|
||
database: Database
|
||
) => {
|
||
this.logger.info(
|
||
`labels association - ${data?.association?.chatId} (${data.type}-${data?.association?.type}): ${data?.association?.labelId}`
|
||
);
|
||
if (database.SAVE_DATA.CHATS) {
|
||
const instanceId = this.instanceId;
|
||
const chatId = data.association.chatId;
|
||
const labelId = data.association.labelId;
|
||
|
||
if (data.type === "add") {
|
||
await this.addLabel(labelId, instanceId, chatId);
|
||
} else if (data.type === "remove") {
|
||
await this.removeLabel(labelId, instanceId, chatId);
|
||
}
|
||
}
|
||
|
||
this.sendDataWebhook(Events.LABELS_ASSOCIATION, {
|
||
instance: this.instance.name,
|
||
type: data.type,
|
||
chatId: data.association.chatId,
|
||
labelId: data.association.labelId,
|
||
});
|
||
},
|
||
};
|
||
|
||
private eventHandler() {
|
||
this.client.ev.process(async (events) => {
|
||
if (!this.endSession) {
|
||
const database = this.configService.get<Database>("DATABASE");
|
||
const settings = await this.findSettings();
|
||
|
||
if (events.call) {
|
||
const call = events.call[0];
|
||
|
||
if (settings?.rejectCall && call.status == "offer") {
|
||
this.client.rejectCall(call.id, call.from);
|
||
}
|
||
|
||
if (settings?.msgCall?.trim().length > 0 && call.status == "offer") {
|
||
if (call.from.endsWith("@lid")) {
|
||
call.from =
|
||
await this.client.signalRepository.lidMapping.getPNForLID(
|
||
call.from as string
|
||
);
|
||
}
|
||
const msg = await this.client.sendMessage(call.from, {
|
||
text: settings.msgCall,
|
||
});
|
||
|
||
this.client.ev.emit("messages.upsert", {
|
||
messages: [msg],
|
||
type: "notify",
|
||
});
|
||
}
|
||
|
||
this.sendDataWebhook(Events.CALL, call);
|
||
}
|
||
|
||
if (events["connection.update"]) {
|
||
this.connectionUpdate(events["connection.update"]);
|
||
}
|
||
|
||
if (events["creds.update"]) {
|
||
this.instance.authState.saveCreds();
|
||
}
|
||
|
||
if (events["messaging-history.set"]) {
|
||
const payload = events["messaging-history.set"];
|
||
this.messageHandle["messaging-history.set"](payload);
|
||
}
|
||
|
||
if (events["messages.upsert"]) {
|
||
const payload = events["messages.upsert"];
|
||
|
||
this.messageProcessor.processMessage(payload, settings);
|
||
// this.messageHandle['messages.upsert'](payload, settings);
|
||
}
|
||
|
||
if (events["messages.update"]) {
|
||
const payload = events["messages.update"];
|
||
this.messageHandle["messages.update"](payload, settings);
|
||
}
|
||
|
||
if (events["message-receipt.update"]) {
|
||
const payload = events[
|
||
"message-receipt.update"
|
||
] as MessageUserReceiptUpdate[];
|
||
const remotesJidMap: Record<string, number> = {};
|
||
|
||
for (const event of payload) {
|
||
if (
|
||
typeof event.key.remoteJid === "string" &&
|
||
typeof event.receipt.readTimestamp === "number"
|
||
) {
|
||
remotesJidMap[event.key.remoteJid] = event.receipt.readTimestamp;
|
||
}
|
||
}
|
||
|
||
await Promise.all(
|
||
Object.keys(remotesJidMap).map(async (remoteJid) =>
|
||
this.updateMessagesReadedByTimestamp(
|
||
remoteJid,
|
||
remotesJidMap[remoteJid]
|
||
)
|
||
)
|
||
);
|
||
}
|
||
|
||
if (events["presence.update"]) {
|
||
const payload = events["presence.update"];
|
||
|
||
if (settings?.groupsIgnore && payload.id.includes("@g.us")) {
|
||
return;
|
||
}
|
||
|
||
this.sendDataWebhook(Events.PRESENCE_UPDATE, payload);
|
||
}
|
||
|
||
if (!settings?.groupsIgnore) {
|
||
if (events["groups.upsert"]) {
|
||
const payload = events["groups.upsert"];
|
||
this.groupHandler["groups.upsert"](payload);
|
||
}
|
||
|
||
if (events["groups.update"]) {
|
||
const payload = events["groups.update"];
|
||
this.groupHandler["groups.update"](payload);
|
||
}
|
||
|
||
if (events["group-participants.update"]) {
|
||
const payload = events["group-participants.update"] as any;
|
||
this.groupHandler["group-participants.update"](payload);
|
||
}
|
||
}
|
||
|
||
if (events["chats.upsert"]) {
|
||
const payload = events["chats.upsert"];
|
||
this.chatHandle["chats.upsert"](payload);
|
||
}
|
||
|
||
if (events["chats.update"]) {
|
||
const payload = events["chats.update"];
|
||
this.chatHandle["chats.update"](payload);
|
||
}
|
||
|
||
if (events["chats.delete"]) {
|
||
const payload = events["chats.delete"];
|
||
this.chatHandle["chats.delete"](payload);
|
||
}
|
||
|
||
if (events["contacts.upsert"]) {
|
||
const payload = events["contacts.upsert"];
|
||
this.contactHandle["contacts.upsert"](payload);
|
||
}
|
||
|
||
if (events["contacts.update"]) {
|
||
const payload = events["contacts.update"];
|
||
this.contactHandle["contacts.update"](payload);
|
||
}
|
||
|
||
if (events[Events.LABELS_ASSOCIATION]) {
|
||
const payload = events[Events.LABELS_ASSOCIATION];
|
||
this.labelHandle[Events.LABELS_ASSOCIATION](payload, database);
|
||
return;
|
||
}
|
||
|
||
if (events[Events.LABELS_EDIT]) {
|
||
const payload = events[Events.LABELS_EDIT];
|
||
this.labelHandle[Events.LABELS_EDIT](payload);
|
||
return;
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
private historySyncNotification(msg: proto.Message.IHistorySyncNotification) {
|
||
const instance: InstanceDto = { instanceName: this.instance.name };
|
||
|
||
if (
|
||
this.configService.get<Chatwoot>("CHATWOOT").ENABLED &&
|
||
this.localChatwoot?.enabled &&
|
||
this.localChatwoot.importMessages &&
|
||
this.isSyncNotificationFromUsedSyncType(msg)
|
||
) {
|
||
if (msg.chunkOrder === 1) {
|
||
this.chatwootService.startImportHistoryMessages(instance);
|
||
}
|
||
|
||
if (msg.progress === 100) {
|
||
setTimeout(() => {
|
||
this.chatwootService.importHistoryMessages(instance);
|
||
}, 10000);
|
||
}
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
private isSyncNotificationFromUsedSyncType(
|
||
msg: proto.Message.IHistorySyncNotification
|
||
) {
|
||
return (
|
||
(this.localSettings.syncFullHistory && msg?.syncType === 2) ||
|
||
(!this.localSettings.syncFullHistory && msg?.syncType === 3)
|
||
);
|
||
}
|
||
|
||
public async profilePicture(number: string) {
|
||
const jid = createJid(number);
|
||
|
||
try {
|
||
const profilePictureUrl = await this.client.profilePictureUrl(
|
||
jid,
|
||
"image"
|
||
);
|
||
|
||
return { wuid: jid, profilePictureUrl };
|
||
} catch {
|
||
return { wuid: jid, profilePictureUrl: null };
|
||
}
|
||
}
|
||
|
||
public async getStatus(number: string) {
|
||
const jid = createJid(number);
|
||
|
||
try {
|
||
return {
|
||
wuid: jid,
|
||
status: (await this.client.fetchStatus(jid))[0]?.status,
|
||
};
|
||
} catch {
|
||
return { wuid: jid, status: null };
|
||
}
|
||
}
|
||
|
||
public async fetchProfile(instanceName: string, number?: string) {
|
||
const jid = number ? createJid(number) : this.client?.user?.id;
|
||
|
||
const onWhatsapp = (await this.whatsappNumber({ numbers: [jid] }))?.shift();
|
||
|
||
if (!onWhatsapp.exists) {
|
||
throw new BadRequestException(onWhatsapp);
|
||
}
|
||
|
||
try {
|
||
if (number) {
|
||
const info = (await this.whatsappNumber({ numbers: [jid] }))?.shift();
|
||
const picture = await this.profilePicture(info?.jid);
|
||
const status = await this.getStatus(info?.jid);
|
||
const business = await this.fetchBusinessProfile(info?.jid);
|
||
|
||
return {
|
||
wuid: info?.jid || jid,
|
||
name: info?.name,
|
||
numberExists: info?.exists,
|
||
picture: picture?.profilePictureUrl,
|
||
status: status?.status,
|
||
isBusiness: business.isBusiness,
|
||
email: business?.email,
|
||
description: business?.description,
|
||
website: business?.website?.shift(),
|
||
};
|
||
} else {
|
||
const instanceNames = instanceName ? [instanceName] : null;
|
||
const info: Instance = await waMonitor.instanceInfo(instanceNames);
|
||
const business = await this.fetchBusinessProfile(jid);
|
||
|
||
return {
|
||
wuid: jid,
|
||
name: info?.profileName,
|
||
numberExists: true,
|
||
picture: info?.profilePicUrl,
|
||
status: info?.connectionStatus,
|
||
isBusiness: business.isBusiness,
|
||
email: business?.email,
|
||
description: business?.description,
|
||
website: business?.website?.shift(),
|
||
};
|
||
}
|
||
} catch {
|
||
return {
|
||
wuid: jid,
|
||
name: null,
|
||
picture: null,
|
||
status: null,
|
||
os: null,
|
||
isBusiness: false,
|
||
};
|
||
}
|
||
}
|
||
|
||
public async offerCall({ number, isVideo, callDuration }: OfferCallDto) {
|
||
const jid = createJid(number);
|
||
|
||
try {
|
||
// const call = await this.client.offerCall(jid, isVideo);
|
||
// setTimeout(() => this.client.terminateCall(call.id, call.to), callDuration * 1000);
|
||
|
||
// return call;
|
||
return { id: "123", jid, isVideo, callDuration };
|
||
} catch (error) {
|
||
return error;
|
||
}
|
||
}
|
||
|
||
private async sendMessage(
|
||
sender: string,
|
||
message: any,
|
||
mentions: any,
|
||
linkPreview: any,
|
||
quoted: any,
|
||
messageId?: string,
|
||
ephemeralExpiration?: number,
|
||
contextInfo?: any
|
||
// participants?: GroupParticipant[],
|
||
) {
|
||
sender = sender.toLowerCase();
|
||
|
||
const option: any = { quoted };
|
||
|
||
if (isJidGroup(sender)) {
|
||
option.useCachedGroupMetadata = true;
|
||
// if (participants)
|
||
// option.cachedGroupMetadata = async () => {
|
||
// return { participants: participants as GroupParticipant[] };
|
||
// };
|
||
}
|
||
|
||
if (ephemeralExpiration) option.ephemeralExpiration = ephemeralExpiration;
|
||
|
||
// NOTE: NÃO DEVEMOS GERAR O messageId AQUI, SOMENTE SE VIER INFORMADO POR PARAMETRO. A GERAÇÃO ANTERIOR IMPEDE O WZAP DE IDENTIFICAR A SOURCE.
|
||
if (messageId) option.messageId = messageId;
|
||
|
||
if (message["viewOnceMessage"]) {
|
||
const m = generateWAMessageFromContent(sender, message, {
|
||
timestamp: new Date(),
|
||
userJid: this.instance.wuid,
|
||
messageId,
|
||
quoted,
|
||
});
|
||
const id = await this.client.relayMessage(sender, message, { messageId });
|
||
m.key = {
|
||
id: id,
|
||
remoteJid: sender,
|
||
participant: isPnUser(sender) ? sender : undefined,
|
||
fromMe: true,
|
||
};
|
||
for (const [key, value] of Object.entries(m)) {
|
||
if (!value || (isArray(value) && value.length) === 0) {
|
||
delete m[key];
|
||
}
|
||
}
|
||
return m;
|
||
}
|
||
|
||
if (
|
||
!message["audio"] &&
|
||
!message["poll"] &&
|
||
!message["sticker"] &&
|
||
!message["conversation"] &&
|
||
sender !== "status@broadcast"
|
||
) {
|
||
if (message["reactionMessage"]) {
|
||
return await this.client.sendMessage(
|
||
sender,
|
||
{
|
||
react: {
|
||
text: message["reactionMessage"]["text"],
|
||
key: message["reactionMessage"]["key"],
|
||
},
|
||
} as unknown as AnyMessageContent,
|
||
option as unknown as MiscMessageGenerationOptions
|
||
);
|
||
}
|
||
}
|
||
|
||
if (contextInfo) {
|
||
message["contextInfo"] = contextInfo;
|
||
}
|
||
|
||
if (message["conversation"]) {
|
||
return await this.client.sendMessage(
|
||
sender,
|
||
{
|
||
text: message["conversation"],
|
||
mentions,
|
||
linkPreview: linkPreview,
|
||
contextInfo: message["contextInfo"],
|
||
} as unknown as AnyMessageContent,
|
||
option as unknown as MiscMessageGenerationOptions
|
||
);
|
||
}
|
||
|
||
if (
|
||
!message["audio"] &&
|
||
!message["poll"] &&
|
||
!message["sticker"] &&
|
||
sender != "status@broadcast"
|
||
) {
|
||
return await this.client.sendMessage(
|
||
sender,
|
||
{
|
||
forward: {
|
||
key: { remoteJid: this.instance.wuid, fromMe: true },
|
||
message,
|
||
},
|
||
mentions,
|
||
contextInfo: message["contextInfo"],
|
||
},
|
||
option as unknown as MiscMessageGenerationOptions
|
||
);
|
||
}
|
||
|
||
if (sender === "status@broadcast") {
|
||
let jidList;
|
||
if (message["status"].option.allContacts) {
|
||
const contacts = await this.prismaRepository.contact.findMany({
|
||
where: {
|
||
instanceId: this.instanceId,
|
||
remoteJid: { not: { endsWith: "@g.us" } },
|
||
},
|
||
});
|
||
|
||
jidList = contacts.map((contact) => contact.remoteJid);
|
||
} else {
|
||
jidList = message["status"].option.statusJidList;
|
||
}
|
||
|
||
const batchSize = 10;
|
||
|
||
const batches = Array.from(
|
||
{ length: Math.ceil(jidList.length / batchSize) },
|
||
(_, i) => jidList.slice(i * batchSize, i * batchSize + batchSize)
|
||
);
|
||
|
||
let msgId: string | null = null;
|
||
|
||
let firstMessage: WAMessage;
|
||
|
||
const firstBatch = batches.shift();
|
||
|
||
if (firstBatch) {
|
||
firstMessage = await this.client.sendMessage(
|
||
sender,
|
||
message["status"].content as unknown as AnyMessageContent,
|
||
{
|
||
backgroundColor: message["status"].option.backgroundColor,
|
||
font: message["status"].option.font,
|
||
statusJidList: firstBatch,
|
||
} as unknown as MiscMessageGenerationOptions
|
||
);
|
||
|
||
msgId = firstMessage.key.id;
|
||
}
|
||
|
||
if (batches.length === 0) return firstMessage;
|
||
|
||
await Promise.allSettled(
|
||
batches.map(async (batch) => {
|
||
const messageSent = await this.client.sendMessage(
|
||
sender,
|
||
message["status"].content as unknown as AnyMessageContent,
|
||
{
|
||
backgroundColor: message["status"].option.backgroundColor,
|
||
font: message["status"].option.font,
|
||
statusJidList: batch,
|
||
messageId: msgId,
|
||
} as unknown as MiscMessageGenerationOptions
|
||
);
|
||
|
||
return messageSent;
|
||
})
|
||
);
|
||
|
||
return firstMessage;
|
||
}
|
||
|
||
return await this.client.sendMessage(
|
||
sender,
|
||
message as unknown as AnyMessageContent,
|
||
option as unknown as MiscMessageGenerationOptions
|
||
);
|
||
}
|
||
|
||
private async sendMessageWithTyping<T = proto.IMessage>(
|
||
number: string,
|
||
message: T,
|
||
options?: Options,
|
||
isIntegration = false
|
||
) {
|
||
const isWA = (await this.whatsappNumber({ numbers: [number] }))?.shift();
|
||
|
||
if (
|
||
!isWA.exists &&
|
||
!isJidGroup(isWA.jid) &&
|
||
!isWA.jid.includes("@broadcast")
|
||
) {
|
||
throw new BadRequestException(isWA);
|
||
}
|
||
|
||
const sender = isWA.jid.toLowerCase();
|
||
|
||
this.logger.verbose(`Sending message to ${sender}`);
|
||
|
||
try {
|
||
if (options?.delay) {
|
||
this.logger.verbose(`Typing for ${options.delay}ms to ${sender}`);
|
||
if (options.delay > 20000) {
|
||
let remainingDelay = options.delay;
|
||
while (remainingDelay > 20000) {
|
||
await this.client.presenceSubscribe(sender);
|
||
|
||
await this.client.sendPresenceUpdate(
|
||
(options.presence as WAPresence) ?? "composing",
|
||
sender
|
||
);
|
||
|
||
await delay(20000);
|
||
|
||
await this.client.sendPresenceUpdate("paused", sender);
|
||
|
||
remainingDelay -= 20000;
|
||
}
|
||
if (remainingDelay > 0) {
|
||
await this.client.presenceSubscribe(sender);
|
||
|
||
await this.client.sendPresenceUpdate(
|
||
(options.presence as WAPresence) ?? "composing",
|
||
sender
|
||
);
|
||
|
||
await delay(remainingDelay);
|
||
|
||
await this.client.sendPresenceUpdate("paused", sender);
|
||
}
|
||
} else {
|
||
await this.client.presenceSubscribe(sender);
|
||
|
||
await this.client.sendPresenceUpdate(
|
||
(options.presence as WAPresence) ?? "composing",
|
||
sender
|
||
);
|
||
|
||
await delay(options.delay);
|
||
|
||
await this.client.sendPresenceUpdate("paused", sender);
|
||
}
|
||
}
|
||
|
||
const linkPreview = options?.linkPreview != false ? undefined : false;
|
||
|
||
let quoted: WAMessage;
|
||
|
||
if (options?.quoted) {
|
||
const m = options?.quoted;
|
||
|
||
const msg = m?.message
|
||
? m
|
||
: ((await this.getMessage(m.key, true)) as WAMessage);
|
||
|
||
if (msg) {
|
||
quoted = msg;
|
||
}
|
||
}
|
||
|
||
let messageSent: WAMessage;
|
||
|
||
let mentions: string[];
|
||
let contextInfo: any;
|
||
|
||
if (isJidGroup(sender)) {
|
||
let group;
|
||
try {
|
||
const cache = this.configService.get<CacheConf>("CACHE");
|
||
if (!cache.REDIS.ENABLED && !cache.LOCAL.ENABLED)
|
||
group = await this.findGroup({ groupJid: sender }, "inner");
|
||
else group = await this.getGroupMetadataCache(sender);
|
||
// group = await this.findGroup({ groupJid: sender }, 'inner');
|
||
} catch {
|
||
throw new NotFoundException("Group not found");
|
||
}
|
||
|
||
if (!group) {
|
||
throw new NotFoundException("Group not found");
|
||
}
|
||
|
||
if (options?.mentionsEveryOne) {
|
||
mentions = group.participants.map((participant) => participant.id);
|
||
} else if (options?.mentioned?.length) {
|
||
mentions = options.mentioned.map((mention) => {
|
||
const jid = createJid(mention);
|
||
if (isJidGroup(jid)) {
|
||
return null;
|
||
}
|
||
return jid;
|
||
});
|
||
}
|
||
|
||
messageSent = await this.sendMessage(
|
||
sender,
|
||
message,
|
||
mentions,
|
||
linkPreview,
|
||
quoted,
|
||
null,
|
||
group?.ephemeralDuration
|
||
// group?.participants,
|
||
);
|
||
} else {
|
||
contextInfo = {
|
||
mentionedJid: [],
|
||
groupMentions: [],
|
||
//expiration: 7776000,
|
||
ephemeralSettingTimestamp: {
|
||
low: Math.floor(Date.now() / 1000) - 172800,
|
||
high: 0,
|
||
unsigned: false,
|
||
},
|
||
disappearingMode: { initiator: 0 },
|
||
};
|
||
messageSent = await this.sendMessage(
|
||
sender,
|
||
message,
|
||
mentions,
|
||
linkPreview,
|
||
quoted,
|
||
null,
|
||
undefined,
|
||
contextInfo
|
||
);
|
||
}
|
||
|
||
if (Long.isLong(messageSent?.messageTimestamp)) {
|
||
messageSent.messageTimestamp = messageSent.messageTimestamp?.toNumber();
|
||
}
|
||
|
||
const messageRaw = this.prepareMessage(messageSent);
|
||
|
||
const isMedia =
|
||
messageSent?.message?.imageMessage ||
|
||
messageSent?.message?.videoMessage ||
|
||
messageSent?.message?.stickerMessage ||
|
||
messageSent?.message?.ptvMessage ||
|
||
messageSent?.message?.documentMessage ||
|
||
messageSent?.message?.documentWithCaptionMessage ||
|
||
messageSent?.message?.ptvMessage ||
|
||
messageSent?.message?.audioMessage;
|
||
|
||
const isVideo = messageSent?.message?.videoMessage;
|
||
|
||
if (
|
||
this.configService.get<Chatwoot>("CHATWOOT").ENABLED &&
|
||
this.localChatwoot?.enabled &&
|
||
!isIntegration
|
||
) {
|
||
this.chatwootService.eventWhatsapp(
|
||
Events.SEND_MESSAGE,
|
||
{ instanceName: this.instance.name, instanceId: this.instanceId },
|
||
messageRaw
|
||
);
|
||
}
|
||
|
||
if (
|
||
this.configService.get<Openai>("OPENAI").ENABLED &&
|
||
messageRaw?.message?.audioMessage
|
||
) {
|
||
const openAiDefaultSettings =
|
||
await this.prismaRepository.openaiSetting.findFirst({
|
||
where: { instanceId: this.instanceId },
|
||
include: { OpenaiCreds: true },
|
||
});
|
||
|
||
if (
|
||
openAiDefaultSettings &&
|
||
openAiDefaultSettings.openaiCredsId &&
|
||
openAiDefaultSettings.speechToText
|
||
) {
|
||
messageRaw.message.speechToText = `[audio] ${await this.openaiService.speechToText(
|
||
messageRaw,
|
||
this
|
||
)}`;
|
||
}
|
||
}
|
||
|
||
if (this.configService.get<Database>("DATABASE").SAVE_DATA.NEW_MESSAGE) {
|
||
const msg = await this.prismaRepository.message.create({
|
||
data: messageRaw,
|
||
});
|
||
|
||
if (isMedia && this.configService.get<S3>("S3").ENABLE) {
|
||
try {
|
||
if (isVideo && !this.configService.get<S3>("S3").SAVE_VIDEO) {
|
||
throw new Error("Video upload is disabled.");
|
||
}
|
||
|
||
const message: any = messageRaw;
|
||
|
||
// Verificação adicional para garantir que há conteúdo de mídia real
|
||
const hasRealMedia = this.hasValidMediaContent(message);
|
||
|
||
if (!hasRealMedia) {
|
||
this.logger.warn(
|
||
"Message detected as media but contains no valid media content"
|
||
);
|
||
} else {
|
||
const media = await this.getBase64FromMediaMessage(
|
||
{ message },
|
||
true
|
||
);
|
||
|
||
const { buffer, mediaType, fileName, size } = media;
|
||
|
||
const mimetype = mimeTypes.lookup(fileName).toString();
|
||
|
||
const fullName = join(
|
||
`${this.instance.id}`,
|
||
messageRaw.key.remoteJid,
|
||
`${messageRaw.key.id}`,
|
||
mediaType,
|
||
fileName
|
||
);
|
||
|
||
await s3Service.uploadFile(
|
||
fullName,
|
||
buffer,
|
||
size.fileLength?.low,
|
||
{ "Content-Type": mimetype }
|
||
);
|
||
|
||
await this.prismaRepository.media.create({
|
||
data: {
|
||
messageId: msg.id,
|
||
instanceId: this.instanceId,
|
||
type: mediaType,
|
||
fileName: fullName,
|
||
mimetype,
|
||
},
|
||
});
|
||
|
||
const mediaUrl = await s3Service.getObjectUrl(fullName);
|
||
|
||
messageRaw.message.mediaUrl = mediaUrl;
|
||
|
||
await this.prismaRepository.message.update({
|
||
where: { id: msg.id },
|
||
data: messageRaw,
|
||
});
|
||
}
|
||
} catch (error) {
|
||
this.logger.error([
|
||
"Error on upload file to minio",
|
||
error?.message,
|
||
error?.stack,
|
||
]);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (this.localWebhook.enabled) {
|
||
if (isMedia && this.localWebhook.webhookBase64) {
|
||
try {
|
||
const buffer = await downloadMediaMessage(
|
||
{ key: messageRaw.key, message: messageRaw?.message },
|
||
"buffer",
|
||
{},
|
||
{
|
||
logger: P({ level: "error" }) as any,
|
||
reuploadRequest: this.client.updateMediaMessage,
|
||
}
|
||
);
|
||
|
||
if (buffer) {
|
||
messageRaw.message.base64 = buffer.toString("base64");
|
||
} else {
|
||
// retry to download media
|
||
const buffer = await downloadMediaMessage(
|
||
{ key: messageRaw.key, message: messageRaw?.message },
|
||
"buffer",
|
||
{},
|
||
{
|
||
logger: P({ level: "error" }) as any,
|
||
reuploadRequest: this.client.updateMediaMessage,
|
||
}
|
||
);
|
||
|
||
if (buffer) {
|
||
messageRaw.message.base64 = buffer.toString("base64");
|
||
}
|
||
}
|
||
} catch (error) {
|
||
this.logger.error([
|
||
"Error converting media to base64",
|
||
error?.message,
|
||
]);
|
||
}
|
||
}
|
||
}
|
||
|
||
this.logger.verbose(messageSent);
|
||
|
||
this.sendDataWebhook(Events.SEND_MESSAGE, messageRaw);
|
||
|
||
if (
|
||
this.configService.get<Chatwoot>("CHATWOOT").ENABLED &&
|
||
this.localChatwoot?.enabled &&
|
||
isIntegration
|
||
) {
|
||
await chatbotController.emit({
|
||
instance: {
|
||
instanceName: this.instance.name,
|
||
instanceId: this.instanceId,
|
||
},
|
||
remoteJid: messageRaw.key.remoteJid,
|
||
msg: messageRaw,
|
||
pushName: messageRaw.pushName,
|
||
isIntegration,
|
||
});
|
||
}
|
||
|
||
return messageRaw;
|
||
} catch (error) {
|
||
this.logger.error(error);
|
||
throw new BadRequestException(error.toString());
|
||
}
|
||
}
|
||
|
||
// Instance Controller
|
||
public async sendPresence(data: SendPresenceDto) {
|
||
try {
|
||
const { number } = data;
|
||
|
||
const isWA = (await this.whatsappNumber({ numbers: [number] }))?.shift();
|
||
|
||
if (
|
||
!isWA.exists &&
|
||
!isJidGroup(isWA.jid) &&
|
||
!isWA.jid.includes("@broadcast")
|
||
) {
|
||
throw new BadRequestException(isWA);
|
||
}
|
||
|
||
const sender = isWA.jid;
|
||
|
||
if (data?.delay && data?.delay > 20000) {
|
||
let remainingDelay = data?.delay;
|
||
while (remainingDelay > 20000) {
|
||
await this.client.presenceSubscribe(sender);
|
||
|
||
await this.client.sendPresenceUpdate(
|
||
(data?.presence as WAPresence) ?? "composing",
|
||
sender
|
||
);
|
||
|
||
await delay(20000);
|
||
|
||
await this.client.sendPresenceUpdate("paused", sender);
|
||
|
||
remainingDelay -= 20000;
|
||
}
|
||
if (remainingDelay > 0) {
|
||
await this.client.presenceSubscribe(sender);
|
||
|
||
await this.client.sendPresenceUpdate(
|
||
(data?.presence as WAPresence) ?? "composing",
|
||
sender
|
||
);
|
||
|
||
await delay(remainingDelay);
|
||
|
||
await this.client.sendPresenceUpdate("paused", sender);
|
||
}
|
||
} else {
|
||
await this.client.presenceSubscribe(sender);
|
||
|
||
await this.client.sendPresenceUpdate(
|
||
(data?.presence as WAPresence) ?? "composing",
|
||
sender
|
||
);
|
||
|
||
await delay(data?.delay);
|
||
|
||
await this.client.sendPresenceUpdate("paused", sender);
|
||
}
|
||
|
||
return { presence: data.presence };
|
||
} catch (error) {
|
||
this.logger.error(error);
|
||
throw new BadRequestException(error.toString());
|
||
}
|
||
}
|
||
|
||
// Presence Controller
|
||
public async setPresence(data: SetPresenceDto) {
|
||
try {
|
||
await this.client.sendPresenceUpdate(data.presence);
|
||
|
||
return { presence: data.presence };
|
||
} catch (error) {
|
||
this.logger.error(error);
|
||
throw new BadRequestException(error.toString());
|
||
}
|
||
}
|
||
|
||
// Send Message Controller
|
||
public async textMessage(data: SendTextDto, isIntegration = false) {
|
||
const text = data.text;
|
||
|
||
if (!text || text.trim().length === 0) {
|
||
throw new BadRequestException("Text is required");
|
||
}
|
||
|
||
return await this.sendMessageWithTyping(
|
||
data.number,
|
||
{ conversation: data.text },
|
||
{
|
||
delay: data?.delay,
|
||
presence: "composing",
|
||
quoted: data?.quoted,
|
||
linkPreview: data?.linkPreview,
|
||
mentionsEveryOne: data?.mentionsEveryOne,
|
||
mentioned: data?.mentioned,
|
||
},
|
||
isIntegration
|
||
);
|
||
}
|
||
|
||
public async pollMessage(data: SendPollDto) {
|
||
return await this.sendMessageWithTyping(
|
||
data.number,
|
||
{
|
||
poll: {
|
||
name: data.name,
|
||
selectableCount: data.selectableCount,
|
||
values: data.values,
|
||
},
|
||
},
|
||
{
|
||
delay: data?.delay,
|
||
presence: "composing",
|
||
quoted: data?.quoted,
|
||
linkPreview: data?.linkPreview,
|
||
mentionsEveryOne: data?.mentionsEveryOne,
|
||
mentioned: data?.mentioned,
|
||
}
|
||
);
|
||
}
|
||
|
||
private async formatStatusMessage(status: StatusMessage) {
|
||
if (!status.type) {
|
||
throw new BadRequestException("Type is required");
|
||
}
|
||
|
||
if (!status.content) {
|
||
throw new BadRequestException("Content is required");
|
||
}
|
||
|
||
if (status.allContacts) {
|
||
const contacts = await this.prismaRepository.contact.findMany({
|
||
where: { instanceId: this.instanceId },
|
||
});
|
||
|
||
if (!contacts.length) {
|
||
throw new BadRequestException("Contacts not found");
|
||
}
|
||
|
||
status.statusJidList = contacts
|
||
.filter((contact) => contact.pushName)
|
||
.map((contact) => contact.remoteJid);
|
||
}
|
||
|
||
if (!status.statusJidList?.length && !status.allContacts) {
|
||
throw new BadRequestException("StatusJidList is required");
|
||
}
|
||
|
||
if (status.type === "text") {
|
||
if (!status.backgroundColor) {
|
||
throw new BadRequestException("Background color is required");
|
||
}
|
||
|
||
if (!status.font) {
|
||
throw new BadRequestException("Font is required");
|
||
}
|
||
|
||
return {
|
||
content: { text: status.content },
|
||
option: {
|
||
backgroundColor: status.backgroundColor,
|
||
font: status.font,
|
||
statusJidList: status.statusJidList,
|
||
},
|
||
};
|
||
}
|
||
if (status.type === "image") {
|
||
return {
|
||
content: { image: { url: status.content }, caption: status.caption },
|
||
option: { statusJidList: status.statusJidList },
|
||
};
|
||
}
|
||
|
||
if (status.type === "video") {
|
||
return {
|
||
content: { video: { url: status.content }, caption: status.caption },
|
||
option: { statusJidList: status.statusJidList },
|
||
};
|
||
}
|
||
|
||
if (status.type === "audio") {
|
||
const convert = await this.processAudioMp4(status.content);
|
||
if (Buffer.isBuffer(convert)) {
|
||
const result = {
|
||
content: {
|
||
audio: convert,
|
||
ptt: true,
|
||
mimetype: "audio/ogg; codecs=opus",
|
||
},
|
||
option: { statusJidList: status.statusJidList },
|
||
};
|
||
|
||
return result;
|
||
} else {
|
||
throw new InternalServerErrorException(convert);
|
||
}
|
||
}
|
||
|
||
throw new BadRequestException("Type not found");
|
||
}
|
||
|
||
public async statusMessage(data: SendStatusDto, file?: any) {
|
||
const mediaData: SendStatusDto = { ...data };
|
||
|
||
if (file) mediaData.content = file.buffer.toString("base64");
|
||
|
||
const status = await this.formatStatusMessage(mediaData);
|
||
|
||
const statusSent = await this.sendMessageWithTyping("status@broadcast", {
|
||
status,
|
||
});
|
||
|
||
return statusSent;
|
||
}
|
||
|
||
private async prepareMediaMessage(mediaMessage: MediaMessage) {
|
||
try {
|
||
const type =
|
||
mediaMessage.mediatype === "ptv" ? "video" : mediaMessage.mediatype;
|
||
|
||
let mediaInput: any;
|
||
if (mediaMessage.mediatype === "image") {
|
||
let imageBuffer: Buffer;
|
||
if (isURL(mediaMessage.media)) {
|
||
let config: any = { responseType: "arraybuffer" };
|
||
|
||
if (this.localProxy?.enabled) {
|
||
config = {
|
||
...config,
|
||
httpsAgent: makeProxyAgent({
|
||
host: this.localProxy.host,
|
||
port: this.localProxy.port,
|
||
protocol: this.localProxy.protocol,
|
||
username: this.localProxy.username,
|
||
password: this.localProxy.password,
|
||
}),
|
||
};
|
||
}
|
||
|
||
const response = await axios.get(mediaMessage.media, config);
|
||
imageBuffer = Buffer.from(response.data, "binary");
|
||
} else {
|
||
imageBuffer = Buffer.from(mediaMessage.media, "base64");
|
||
}
|
||
|
||
mediaInput = await sharp(imageBuffer).jpeg().toBuffer();
|
||
mediaMessage.fileName ??= "image.jpg";
|
||
mediaMessage.mimetype = "image/jpeg";
|
||
} else {
|
||
mediaInput = isURL(mediaMessage.media)
|
||
? { url: mediaMessage.media }
|
||
: Buffer.from(mediaMessage.media, "base64");
|
||
}
|
||
|
||
const prepareMedia = await prepareWAMessageMedia(
|
||
{
|
||
[type]: mediaInput,
|
||
} as any,
|
||
{ upload: this.client.waUploadToServer }
|
||
);
|
||
|
||
const mediaType = mediaMessage.mediatype + "Message";
|
||
|
||
if (mediaMessage.mediatype === "document" && !mediaMessage.fileName) {
|
||
const regex = new RegExp(/.*\/(.+?)\./);
|
||
const arrayMatch = regex.exec(mediaMessage.media);
|
||
mediaMessage.fileName = arrayMatch[1];
|
||
}
|
||
|
||
if (mediaMessage.mediatype === "image" && !mediaMessage.fileName) {
|
||
mediaMessage.fileName = "image.jpg";
|
||
}
|
||
|
||
if (mediaMessage.mediatype === "video" && !mediaMessage.fileName) {
|
||
mediaMessage.fileName = "video.mp4";
|
||
}
|
||
|
||
let mimetype: string | false;
|
||
|
||
if (mediaMessage.mimetype) {
|
||
mimetype = mediaMessage.mimetype;
|
||
} else {
|
||
mimetype = mimeTypes.lookup(mediaMessage.fileName);
|
||
|
||
if (!mimetype && isURL(mediaMessage.media)) {
|
||
let config: any = { responseType: "arraybuffer" };
|
||
|
||
if (this.localProxy?.enabled) {
|
||
config = {
|
||
...config,
|
||
httpsAgent: makeProxyAgent({
|
||
host: this.localProxy.host,
|
||
port: this.localProxy.port,
|
||
protocol: this.localProxy.protocol,
|
||
username: this.localProxy.username,
|
||
password: this.localProxy.password,
|
||
}),
|
||
};
|
||
}
|
||
|
||
const response = await axios.get(mediaMessage.media, config);
|
||
|
||
mimetype = response.headers["content-type"];
|
||
}
|
||
}
|
||
|
||
if (mediaMessage.mediatype === "ptv") {
|
||
prepareMedia[mediaType] = prepareMedia[type + "Message"];
|
||
mimetype = "video/mp4";
|
||
|
||
if (!prepareMedia[mediaType]) {
|
||
throw new Error("Failed to prepare video message");
|
||
}
|
||
|
||
try {
|
||
let mediaInput;
|
||
if (isURL(mediaMessage.media)) {
|
||
mediaInput = mediaMessage.media;
|
||
} else {
|
||
const mediaBuffer = Buffer.from(mediaMessage.media, "base64");
|
||
if (!mediaBuffer || mediaBuffer.length === 0) {
|
||
throw new Error("Invalid media buffer");
|
||
}
|
||
mediaInput = mediaBuffer;
|
||
}
|
||
|
||
const duration = await getVideoDuration(mediaInput);
|
||
if (!duration || duration <= 0) {
|
||
throw new Error("Invalid media duration");
|
||
}
|
||
|
||
this.logger.verbose(`Video duration: ${duration} seconds`);
|
||
prepareMedia[mediaType].seconds = duration;
|
||
} catch (error) {
|
||
this.logger.error("Error getting video duration:");
|
||
this.logger.error(error);
|
||
throw new Error(`Failed to get video duration: ${error.message}`);
|
||
}
|
||
}
|
||
|
||
if (mediaMessage?.fileName) {
|
||
mimetype = mimeTypes.lookup(mediaMessage.fileName).toString();
|
||
if (mimetype === "application/mp4") {
|
||
mimetype = "video/mp4";
|
||
}
|
||
}
|
||
|
||
prepareMedia[mediaType].caption = mediaMessage?.caption;
|
||
prepareMedia[mediaType].mimetype = mimetype;
|
||
prepareMedia[mediaType].fileName = mediaMessage.fileName;
|
||
|
||
if (mediaMessage.mediatype === "video") {
|
||
prepareMedia[mediaType].gifPlayback = false;
|
||
}
|
||
|
||
return generateWAMessageFromContent(
|
||
"",
|
||
{ [mediaType]: { ...prepareMedia[mediaType] } },
|
||
{ userJid: this.instance.wuid }
|
||
);
|
||
} catch (error) {
|
||
this.logger.error(error);
|
||
throw new InternalServerErrorException(error?.toString() || error);
|
||
}
|
||
}
|
||
|
||
private async convertToWebP(image: string): Promise<Buffer> {
|
||
try {
|
||
let imageBuffer: Buffer;
|
||
|
||
if (isBase64(image)) {
|
||
const base64Data = image.replace(
|
||
/^data:image\/(jpeg|png|gif);base64,/,
|
||
""
|
||
);
|
||
imageBuffer = Buffer.from(base64Data, "base64");
|
||
} else {
|
||
const timestamp = new Date().getTime();
|
||
const parsedURL = new URL(image);
|
||
parsedURL.searchParams.set("timestamp", timestamp.toString());
|
||
const url = parsedURL.toString();
|
||
|
||
let config: any = { responseType: "arraybuffer" };
|
||
|
||
if (this.localProxy?.enabled) {
|
||
config = {
|
||
...config,
|
||
httpsAgent: makeProxyAgent({
|
||
host: this.localProxy.host,
|
||
port: this.localProxy.port,
|
||
protocol: this.localProxy.protocol,
|
||
username: this.localProxy.username,
|
||
password: this.localProxy.password,
|
||
}),
|
||
};
|
||
}
|
||
|
||
const response = await axios.get(url, config);
|
||
imageBuffer = Buffer.from(response.data, "binary");
|
||
}
|
||
|
||
const isAnimated = this.isAnimated(image, imageBuffer);
|
||
|
||
if (isAnimated) {
|
||
return await sharp(imageBuffer, { animated: true })
|
||
.webp({ quality: 80 })
|
||
.toBuffer();
|
||
} else {
|
||
return await sharp(imageBuffer).webp().toBuffer();
|
||
}
|
||
} catch (error) {
|
||
console.error("Erro ao converter a imagem para WebP:", error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
private isAnimatedWebp(buffer: Buffer): boolean {
|
||
if (buffer.length < 12) return false;
|
||
|
||
return buffer.indexOf(Buffer.from("ANIM")) !== -1;
|
||
}
|
||
|
||
private isAnimated(image: string, buffer: Buffer): boolean {
|
||
const lowerCaseImage = image.toLowerCase();
|
||
|
||
if (lowerCaseImage.includes(".gif")) return true;
|
||
|
||
if (lowerCaseImage.includes(".webp")) return this.isAnimatedWebp(buffer);
|
||
|
||
return false;
|
||
}
|
||
|
||
public async mediaSticker(data: SendStickerDto, file?: any) {
|
||
const mediaData: SendStickerDto = { ...data };
|
||
|
||
if (file) mediaData.sticker = file.buffer.toString("base64");
|
||
|
||
const convert = data?.notConvertSticker
|
||
? Buffer.from(data.sticker, "base64")
|
||
: await this.convertToWebP(data.sticker);
|
||
const gifPlayback = data.sticker.includes(".gif");
|
||
const result = await this.sendMessageWithTyping(
|
||
data.number,
|
||
{ sticker: convert, gifPlayback },
|
||
{
|
||
delay: data?.delay,
|
||
presence: "composing",
|
||
quoted: data?.quoted,
|
||
mentionsEveryOne: data?.mentionsEveryOne,
|
||
mentioned: data?.mentioned,
|
||
}
|
||
);
|
||
|
||
return result;
|
||
}
|
||
|
||
public async mediaMessage(
|
||
data: SendMediaDto,
|
||
file?: any,
|
||
isIntegration = false
|
||
) {
|
||
const mediaData: SendMediaDto = { ...data };
|
||
|
||
if (file) mediaData.media = file.buffer.toString("base64");
|
||
|
||
const generate = await this.prepareMediaMessage(mediaData);
|
||
|
||
const mediaSent = await this.sendMessageWithTyping(
|
||
data.number,
|
||
{ ...generate.message },
|
||
{
|
||
delay: data?.delay,
|
||
presence: "composing",
|
||
quoted: data?.quoted,
|
||
mentionsEveryOne: data?.mentionsEveryOne,
|
||
mentioned: data?.mentioned,
|
||
},
|
||
isIntegration
|
||
);
|
||
|
||
return mediaSent;
|
||
}
|
||
|
||
public async ptvMessage(data: SendPtvDto, file?: any, isIntegration = false) {
|
||
const mediaData: SendMediaDto = {
|
||
number: data.number,
|
||
media: data.video,
|
||
mediatype: "ptv",
|
||
delay: data?.delay,
|
||
quoted: data?.quoted,
|
||
mentionsEveryOne: data?.mentionsEveryOne,
|
||
mentioned: data?.mentioned,
|
||
};
|
||
|
||
if (file) mediaData.media = file.buffer.toString("base64");
|
||
|
||
const generate = await this.prepareMediaMessage(mediaData);
|
||
|
||
const mediaSent = await this.sendMessageWithTyping(
|
||
data.number,
|
||
{ ...generate.message },
|
||
{
|
||
delay: data?.delay,
|
||
presence: "composing",
|
||
quoted: data?.quoted,
|
||
mentionsEveryOne: data?.mentionsEveryOne,
|
||
mentioned: data?.mentioned,
|
||
},
|
||
isIntegration
|
||
);
|
||
|
||
return mediaSent;
|
||
}
|
||
|
||
public async processAudioMp4(audio: string) {
|
||
let inputStream: PassThrough;
|
||
|
||
if (isURL(audio)) {
|
||
const response = await axios.get(audio, { responseType: "stream" });
|
||
inputStream = response.data;
|
||
} else {
|
||
const audioBuffer = Buffer.from(audio, "base64");
|
||
inputStream = new PassThrough();
|
||
inputStream.end(audioBuffer);
|
||
}
|
||
|
||
return new Promise<Buffer>((resolve, reject) => {
|
||
const ffmpegProcess = spawn(ffmpegPath.path, [
|
||
"-i",
|
||
"pipe:0",
|
||
"-vn",
|
||
"-ab",
|
||
"128k",
|
||
"-ar",
|
||
"44100",
|
||
"-f",
|
||
"mp4",
|
||
"-movflags",
|
||
"frag_keyframe+empty_moov",
|
||
"pipe:1",
|
||
]);
|
||
|
||
const outputChunks: Buffer[] = [];
|
||
let stderrData = "";
|
||
|
||
ffmpegProcess.stdout.on("data", (chunk) => {
|
||
outputChunks.push(chunk);
|
||
});
|
||
|
||
ffmpegProcess.stderr.on("data", (data) => {
|
||
stderrData += data.toString();
|
||
this.logger.verbose(`ffmpeg stderr: ${data}`);
|
||
});
|
||
|
||
ffmpegProcess.on("error", (error) => {
|
||
console.error("Error in ffmpeg process", error);
|
||
reject(error);
|
||
});
|
||
|
||
ffmpegProcess.on("close", (code) => {
|
||
if (code === 0) {
|
||
this.logger.verbose("Audio converted to mp4");
|
||
const outputBuffer = Buffer.concat(outputChunks);
|
||
resolve(outputBuffer);
|
||
} else {
|
||
this.logger.error(`ffmpeg exited with code ${code}`);
|
||
this.logger.error(`ffmpeg stderr: ${stderrData}`);
|
||
reject(new Error(`ffmpeg exited with code ${code}: ${stderrData}`));
|
||
}
|
||
});
|
||
|
||
inputStream.pipe(ffmpegProcess.stdin);
|
||
|
||
inputStream.on("error", (err) => {
|
||
console.error("Error in inputStream", err);
|
||
ffmpegProcess.stdin.end();
|
||
reject(err);
|
||
});
|
||
});
|
||
}
|
||
|
||
public async processAudio(audio: string): Promise<Buffer> {
|
||
const audioConverterConfig =
|
||
this.configService.get<AudioConverter>("AUDIO_CONVERTER");
|
||
if (audioConverterConfig.API_URL) {
|
||
this.logger.verbose("Using audio converter API");
|
||
const formData = new FormData();
|
||
|
||
if (isURL(audio)) {
|
||
formData.append("url", audio);
|
||
} else {
|
||
formData.append("base64", audio);
|
||
}
|
||
|
||
const { data } = await axios.post(
|
||
audioConverterConfig.API_URL,
|
||
formData,
|
||
{
|
||
headers: {
|
||
...formData.getHeaders(),
|
||
apikey: audioConverterConfig.API_KEY,
|
||
},
|
||
}
|
||
);
|
||
|
||
if (!data.audio) {
|
||
throw new InternalServerErrorException("Failed to convert audio");
|
||
}
|
||
|
||
this.logger.verbose("Audio converted");
|
||
return Buffer.from(data.audio, "base64");
|
||
} else {
|
||
let inputAudioStream: PassThrough;
|
||
|
||
if (isURL(audio)) {
|
||
const timestamp = new Date().getTime();
|
||
const parsedURL = new URL(audio);
|
||
parsedURL.searchParams.set("timestamp", timestamp.toString());
|
||
const url = parsedURL.toString();
|
||
|
||
const config: any = { responseType: "stream" };
|
||
|
||
const response = await axios.get(url, config);
|
||
inputAudioStream = response.data.pipe(new PassThrough());
|
||
} else {
|
||
const audioBuffer = Buffer.from(audio, "base64");
|
||
inputAudioStream = new PassThrough();
|
||
inputAudioStream.end(audioBuffer);
|
||
}
|
||
|
||
const isLpcm = isURL(audio) && /\.lpcm($|\?)/i.test(audio);
|
||
|
||
return new Promise((resolve, reject) => {
|
||
const outputAudioStream = new PassThrough();
|
||
const chunks: Buffer[] = [];
|
||
|
||
outputAudioStream.on("data", (chunk) => chunks.push(chunk));
|
||
outputAudioStream.on("end", () => {
|
||
const outputBuffer = Buffer.concat(chunks);
|
||
resolve(outputBuffer);
|
||
});
|
||
|
||
outputAudioStream.on("error", (error) => {
|
||
console.log("error", error);
|
||
reject(error);
|
||
});
|
||
|
||
ffmpeg.setFfmpegPath(ffmpegPath.path);
|
||
|
||
let command = ffmpeg(inputAudioStream);
|
||
|
||
if (isLpcm) {
|
||
this.logger.verbose(
|
||
"Detected LPCM input – applying raw PCM settings"
|
||
);
|
||
command = command
|
||
.inputFormat("s16le")
|
||
.inputOptions(["-ar", "24000", "-ac", "1"]);
|
||
}
|
||
|
||
command
|
||
.outputFormat("ogg")
|
||
.noVideo()
|
||
.audioCodec("libopus")
|
||
.addOutputOptions("-avoid_negative_ts make_zero")
|
||
.audioBitrate("128k")
|
||
.audioFrequency(48000)
|
||
.audioChannels(1)
|
||
.outputOptions([
|
||
"-write_xing",
|
||
"0",
|
||
"-compression_level",
|
||
"10",
|
||
"-application",
|
||
"voip",
|
||
"-fflags",
|
||
"+bitexact",
|
||
"-flags",
|
||
"+bitexact",
|
||
"-id3v2_version",
|
||
"0",
|
||
"-map_metadata",
|
||
"-1",
|
||
"-map_chapters",
|
||
"-1",
|
||
"-write_bext",
|
||
"0",
|
||
])
|
||
.pipe(outputAudioStream, { end: true })
|
||
.on("error", function (error) {
|
||
console.log("error", error);
|
||
reject(error);
|
||
});
|
||
});
|
||
}
|
||
}
|
||
|
||
public async audioWhatsapp(
|
||
data: SendAudioDto,
|
||
file?: any,
|
||
isIntegration = false
|
||
) {
|
||
const mediaData: SendAudioDto = { ...data };
|
||
|
||
if (file?.buffer) {
|
||
mediaData.audio = file.buffer.toString("base64");
|
||
} else if (!isURL(data.audio) && !isBase64(data.audio)) {
|
||
console.error("Invalid file or audio source");
|
||
throw new BadRequestException(
|
||
"File buffer, URL, or base64 audio is required"
|
||
);
|
||
}
|
||
|
||
if (!data?.encoding && data?.encoding !== false) {
|
||
data.encoding = true;
|
||
}
|
||
|
||
if (data?.encoding) {
|
||
const convert = await this.processAudio(mediaData.audio);
|
||
|
||
if (Buffer.isBuffer(convert)) {
|
||
const result = this.sendMessageWithTyping<AnyMessageContent>(
|
||
data.number,
|
||
{ audio: convert, ptt: true, mimetype: "audio/ogg; codecs=opus" },
|
||
{ presence: "recording", delay: data?.delay },
|
||
isIntegration
|
||
);
|
||
|
||
return result;
|
||
} else {
|
||
throw new InternalServerErrorException("Failed to convert audio");
|
||
}
|
||
}
|
||
|
||
return await this.sendMessageWithTyping<AnyMessageContent>(
|
||
data.number,
|
||
{
|
||
audio: isURL(data.audio)
|
||
? { url: data.audio }
|
||
: Buffer.from(data.audio, "base64"),
|
||
ptt: true,
|
||
mimetype: "audio/ogg; codecs=opus",
|
||
},
|
||
{ presence: "recording", delay: data?.delay },
|
||
isIntegration
|
||
);
|
||
}
|
||
|
||
private generateRandomId(length = 11) {
|
||
const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||
let result = "";
|
||
for (let i = 0; i < length; i++) {
|
||
result += characters.charAt(
|
||
Math.floor(Math.random() * characters.length)
|
||
);
|
||
}
|
||
return result;
|
||
}
|
||
|
||
private toJSONString(button: Button): string {
|
||
const toString = (obj: any) => JSON.stringify(obj);
|
||
|
||
const json = {
|
||
call: () =>
|
||
toString({
|
||
display_text: button.displayText,
|
||
phone_number: button.phoneNumber,
|
||
}),
|
||
reply: () =>
|
||
toString({ display_text: button.displayText, id: button.id }),
|
||
copy: () =>
|
||
toString({
|
||
display_text: button.displayText,
|
||
copy_code: button.copyCode,
|
||
}),
|
||
url: () =>
|
||
toString({
|
||
display_text: button.displayText,
|
||
url: button.url,
|
||
merchant_url: button.url,
|
||
}),
|
||
pix: () =>
|
||
toString({
|
||
currency: button.currency,
|
||
total_amount: { value: 0, offset: 100 },
|
||
reference_id: this.generateRandomId(),
|
||
type: "physical-goods",
|
||
order: {
|
||
status: "pending",
|
||
subtotal: { value: 0, offset: 100 },
|
||
order_type: "ORDER",
|
||
items: [
|
||
{
|
||
name: "",
|
||
amount: { value: 0, offset: 100 },
|
||
quantity: 0,
|
||
sale_amount: { value: 0, offset: 100 },
|
||
},
|
||
],
|
||
},
|
||
payment_settings: [
|
||
{
|
||
type: "pix_static_code",
|
||
pix_static_code: {
|
||
merchant_name: button.name,
|
||
key: button.key,
|
||
key_type: this.mapKeyType.get(button.keyType),
|
||
},
|
||
},
|
||
],
|
||
share_payment_status: false,
|
||
}),
|
||
};
|
||
|
||
return json[button.type]?.() || "";
|
||
}
|
||
|
||
private readonly mapType = new Map<TypeButton, string>([
|
||
["reply", "quick_reply"],
|
||
["copy", "cta_copy"],
|
||
["url", "cta_url"],
|
||
["call", "cta_call"],
|
||
["pix", "payment_info"],
|
||
]);
|
||
|
||
private readonly mapKeyType = new Map<KeyType, string>([
|
||
["phone", "PHONE"],
|
||
["email", "EMAIL"],
|
||
["cpf", "CPF"],
|
||
["cnpj", "CNPJ"],
|
||
["random", "EVP"],
|
||
]);
|
||
|
||
public async buttonMessage(data: SendButtonsDto) {
|
||
if (data.buttons.length === 0) {
|
||
throw new BadRequestException("At least one button is required");
|
||
}
|
||
|
||
const hasReplyButtons = data.buttons.some((btn) => btn.type === "reply");
|
||
|
||
const hasPixButton = data.buttons.some((btn) => btn.type === "pix");
|
||
|
||
const hasOtherButtons = data.buttons.some(
|
||
(btn) => btn.type !== "reply" && btn.type !== "pix"
|
||
);
|
||
|
||
if (hasReplyButtons) {
|
||
if (data.buttons.length > 3) {
|
||
throw new BadRequestException("Maximum of 3 reply buttons allowed");
|
||
}
|
||
if (hasOtherButtons) {
|
||
throw new BadRequestException(
|
||
"Reply buttons cannot be mixed with other button types"
|
||
);
|
||
}
|
||
}
|
||
|
||
if (hasPixButton) {
|
||
if (data.buttons.length > 1) {
|
||
throw new BadRequestException("Only one PIX button is allowed");
|
||
}
|
||
if (hasOtherButtons) {
|
||
throw new BadRequestException(
|
||
"PIX button cannot be mixed with other button types"
|
||
);
|
||
}
|
||
|
||
const message: proto.IMessage = {
|
||
viewOnceMessage: {
|
||
message: {
|
||
interactiveMessage: {
|
||
nativeFlowMessage: {
|
||
buttons: [
|
||
{
|
||
name: this.mapType.get("pix"),
|
||
buttonParamsJson: this.toJSONString(data.buttons[0]),
|
||
},
|
||
],
|
||
messageParamsJson: JSON.stringify({
|
||
from: "api",
|
||
templateId: v4(),
|
||
}),
|
||
},
|
||
},
|
||
},
|
||
},
|
||
};
|
||
|
||
return await this.sendMessageWithTyping(data.number, message, {
|
||
delay: data?.delay,
|
||
presence: "composing",
|
||
quoted: data?.quoted,
|
||
mentionsEveryOne: data?.mentionsEveryOne,
|
||
mentioned: data?.mentioned,
|
||
});
|
||
}
|
||
|
||
const generate = await (async () => {
|
||
if (data?.thumbnailUrl) {
|
||
return await this.prepareMediaMessage({
|
||
mediatype: "image",
|
||
media: data.thumbnailUrl,
|
||
});
|
||
}
|
||
})();
|
||
|
||
const buttons = data.buttons.map((value) => {
|
||
return {
|
||
name: this.mapType.get(value.type),
|
||
buttonParamsJson: this.toJSONString(value),
|
||
};
|
||
});
|
||
|
||
const message: proto.IMessage = {
|
||
viewOnceMessage: {
|
||
message: {
|
||
interactiveMessage: {
|
||
body: {
|
||
text: (() => {
|
||
let t = "*" + data.title + "*";
|
||
if (data?.description) {
|
||
t += "\n\n";
|
||
t += data.description;
|
||
t += "\n";
|
||
}
|
||
return t;
|
||
})(),
|
||
},
|
||
footer: { text: data?.footer },
|
||
header: (() => {
|
||
if (generate?.message?.imageMessage) {
|
||
return {
|
||
hasMediaAttachment: !!generate.message.imageMessage,
|
||
imageMessage: generate.message.imageMessage,
|
||
};
|
||
}
|
||
})(),
|
||
nativeFlowMessage: {
|
||
buttons: buttons,
|
||
messageParamsJson: JSON.stringify({
|
||
from: "api",
|
||
templateId: v4(),
|
||
}),
|
||
},
|
||
},
|
||
},
|
||
},
|
||
};
|
||
|
||
return await this.sendMessageWithTyping(data.number, message, {
|
||
delay: data?.delay,
|
||
presence: "composing",
|
||
quoted: data?.quoted,
|
||
mentionsEveryOne: data?.mentionsEveryOne,
|
||
mentioned: data?.mentioned,
|
||
});
|
||
}
|
||
|
||
public async locationMessage(data: SendLocationDto) {
|
||
return await this.sendMessageWithTyping(
|
||
data.number,
|
||
{
|
||
locationMessage: {
|
||
degreesLatitude: data.latitude,
|
||
degreesLongitude: data.longitude,
|
||
name: data?.name,
|
||
address: data?.address,
|
||
},
|
||
},
|
||
{
|
||
delay: data?.delay,
|
||
presence: "composing",
|
||
quoted: data?.quoted,
|
||
mentionsEveryOne: data?.mentionsEveryOne,
|
||
mentioned: data?.mentioned,
|
||
}
|
||
);
|
||
}
|
||
|
||
public async listMessage(data: SendListDto) {
|
||
return await this.sendMessageWithTyping(
|
||
data.number,
|
||
{
|
||
listMessage: {
|
||
title: data.title,
|
||
description: data.description,
|
||
buttonText: data?.buttonText,
|
||
footerText: data?.footerText,
|
||
sections: data.sections,
|
||
listType: 2,
|
||
},
|
||
},
|
||
{
|
||
delay: data?.delay,
|
||
presence: "composing",
|
||
quoted: data?.quoted,
|
||
mentionsEveryOne: data?.mentionsEveryOne,
|
||
mentioned: data?.mentioned,
|
||
}
|
||
);
|
||
}
|
||
|
||
public async contactMessage(data: SendContactDto) {
|
||
const message: proto.IMessage = {};
|
||
|
||
const vcard = (contact: ContactMessage) => {
|
||
let result =
|
||
"BEGIN:VCARD\n" +
|
||
"VERSION:3.0\n" +
|
||
`N:${contact.fullName}\n` +
|
||
`FN:${contact.fullName}\n`;
|
||
|
||
if (contact.organization) {
|
||
result += `ORG:${contact.organization};\n`;
|
||
}
|
||
|
||
if (contact.email) {
|
||
result += `EMAIL:${contact.email}\n`;
|
||
}
|
||
|
||
if (contact.url) {
|
||
result += `URL:${contact.url}\n`;
|
||
}
|
||
|
||
if (!contact.wuid) {
|
||
contact.wuid = createJid(contact.phoneNumber);
|
||
}
|
||
|
||
result +=
|
||
`item1.TEL;waid=${contact.wuid}:${contact.phoneNumber}\n` +
|
||
"item1.X-ABLabel:Celular\n" +
|
||
"END:VCARD";
|
||
|
||
return result;
|
||
};
|
||
|
||
if (data.contact.length === 1) {
|
||
message.contactMessage = {
|
||
displayName: data.contact[0].fullName,
|
||
vcard: vcard(data.contact[0]),
|
||
};
|
||
} else {
|
||
message.contactsArrayMessage = {
|
||
displayName: `${data.contact.length} contacts`,
|
||
contacts: data.contact.map((contact) => {
|
||
return { displayName: contact.fullName, vcard: vcard(contact) };
|
||
}),
|
||
};
|
||
}
|
||
|
||
return await this.sendMessageWithTyping(data.number, { ...message }, {});
|
||
}
|
||
|
||
public async reactionMessage(data: SendReactionDto) {
|
||
return await this.sendMessageWithTyping(data.key.remoteJid, {
|
||
reactionMessage: { key: data.key, text: data.reaction },
|
||
});
|
||
}
|
||
|
||
// Chat Controller
|
||
public async whatsappNumber(data: WhatsAppNumberDto) {
|
||
const jids: {
|
||
groups: { number: string; jid: string }[];
|
||
broadcast: { number: string; jid: string }[];
|
||
users: { number: string; jid: string; name?: string }[];
|
||
} = { groups: [], broadcast: [], users: [] };
|
||
|
||
data.numbers.forEach((number) => {
|
||
const jid = createJid(number);
|
||
|
||
if (isJidGroup(jid)) {
|
||
jids.groups.push({ number, jid });
|
||
} else if (jid === "status@broadcast") {
|
||
jids.broadcast.push({ number, jid });
|
||
} else {
|
||
jids.users.push({ number, jid });
|
||
}
|
||
});
|
||
|
||
const onWhatsapp: OnWhatsAppDto[] = [];
|
||
|
||
// BROADCAST
|
||
onWhatsapp.push(
|
||
...jids.broadcast.map(
|
||
({ jid, number }) => new OnWhatsAppDto(jid, false, number)
|
||
)
|
||
);
|
||
|
||
// GROUPS
|
||
const groups = await Promise.all(
|
||
jids.groups.map(async ({ jid, number }) => {
|
||
const group = await this.findGroup({ groupJid: jid }, "inner");
|
||
|
||
if (!group) {
|
||
return new OnWhatsAppDto(jid, false, number);
|
||
}
|
||
|
||
return new OnWhatsAppDto(group.id, true, number, group?.subject);
|
||
})
|
||
);
|
||
onWhatsapp.push(...groups);
|
||
|
||
// USERS
|
||
const contacts: any[] = await this.prismaRepository.contact.findMany({
|
||
where: {
|
||
instanceId: this.instanceId,
|
||
remoteJid: { in: jids.users.map(({ jid }) => jid) },
|
||
},
|
||
});
|
||
|
||
// Unified cache verification for all numbers (normal and LID)
|
||
const numbersToVerify = jids.users.map(({ jid }) => jid.replace("+", ""));
|
||
|
||
// Get all numbers from cache
|
||
const cachedNumbers = await getOnWhatsappCache(numbersToVerify);
|
||
|
||
// Separate numbers that are and are not in cache
|
||
const cachedJids = new Set(
|
||
cachedNumbers.flatMap((cached) => cached.jidOptions)
|
||
);
|
||
const numbersNotInCache = numbersToVerify.filter(
|
||
(jid) => !cachedJids.has(jid)
|
||
);
|
||
|
||
// Only call Baileys for normal numbers (@s.whatsapp.net) that are not in cache
|
||
let verify: { jid: string; exists: boolean }[] = [];
|
||
const normalNumbersNotInCache = numbersNotInCache.filter(
|
||
(jid) => !jid.includes("@lid")
|
||
);
|
||
|
||
if (normalNumbersNotInCache.length > 0) {
|
||
this.logger.verbose(
|
||
`Checking ${normalNumbersNotInCache.length} numbers via Baileys (not found in cache)`
|
||
);
|
||
verify = await this.client.onWhatsApp(...normalNumbersNotInCache);
|
||
}
|
||
|
||
const verifiedUsers = await Promise.all(
|
||
jids.users.map(async (user) => {
|
||
// Try to get from cache first (works for all: normal and LID)
|
||
const cached = cachedNumbers.find((cached) =>
|
||
cached.jidOptions.includes(user.jid.replace("+", ""))
|
||
);
|
||
|
||
if (cached) {
|
||
this.logger.verbose(`Number ${user.number} found in cache`);
|
||
return new OnWhatsAppDto(
|
||
cached.remoteJid,
|
||
true,
|
||
user.number,
|
||
contacts.find((c) => c.remoteJid === cached.remoteJid)?.pushName,
|
||
cached.lid ||
|
||
(cached.remoteJid.includes("@lid") ? "lid" : undefined)
|
||
);
|
||
}
|
||
|
||
// If it's a LID number and not in cache, consider it valid
|
||
if (user.jid.includes("@lid")) {
|
||
return new OnWhatsAppDto(
|
||
user.jid,
|
||
true,
|
||
user.number,
|
||
contacts.find((c) => c.remoteJid === user.jid)?.pushName,
|
||
"lid"
|
||
);
|
||
}
|
||
|
||
// If not in cache and is a normal number, use Baileys verification
|
||
let numberVerified: (typeof verify)[0] | null = null;
|
||
|
||
// Brazilian numbers
|
||
if (user.number.startsWith("55")) {
|
||
const numberWithDigit =
|
||
user.number.slice(4, 5) === "9" && user.number.length === 13
|
||
? user.number
|
||
: `${user.number.slice(0, 4)}9${user.number.slice(4)}`;
|
||
const numberWithoutDigit =
|
||
user.number.length === 12
|
||
? user.number
|
||
: user.number.slice(0, 4) + user.number.slice(5);
|
||
|
||
numberVerified = verify.find(
|
||
(v) =>
|
||
v.jid === `${numberWithDigit}@s.whatsapp.net` ||
|
||
v.jid === `${numberWithoutDigit}@s.whatsapp.net`
|
||
);
|
||
}
|
||
|
||
// Mexican/Argentina numbers
|
||
// Ref: https://faq.whatsapp.com/1294841057948784
|
||
if (
|
||
!numberVerified &&
|
||
(user.number.startsWith("52") || user.number.startsWith("54"))
|
||
) {
|
||
let prefix = "";
|
||
if (user.number.startsWith("52")) {
|
||
prefix = "1";
|
||
}
|
||
if (user.number.startsWith("54")) {
|
||
prefix = "9";
|
||
}
|
||
|
||
const numberWithDigit =
|
||
user.number.slice(2, 3) === prefix && user.number.length === 13
|
||
? user.number
|
||
: `${user.number.slice(0, 2)}${prefix}${user.number.slice(2)}`;
|
||
const numberWithoutDigit =
|
||
user.number.length === 12
|
||
? user.number
|
||
: user.number.slice(0, 2) + user.number.slice(3);
|
||
|
||
numberVerified = verify.find(
|
||
(v) =>
|
||
v.jid === `${numberWithDigit}@s.whatsapp.net` ||
|
||
v.jid === `${numberWithoutDigit}@s.whatsapp.net`
|
||
);
|
||
}
|
||
|
||
if (!numberVerified) {
|
||
numberVerified = verify.find((v) => v.jid === user.jid);
|
||
}
|
||
|
||
const numberJid = numberVerified?.jid || user.jid;
|
||
|
||
return new OnWhatsAppDto(
|
||
numberJid,
|
||
!!numberVerified?.exists,
|
||
user.number,
|
||
contacts.find((c) => c.remoteJid === numberJid)?.pushName,
|
||
undefined
|
||
);
|
||
})
|
||
);
|
||
|
||
// Combine results
|
||
onWhatsapp.push(...verifiedUsers);
|
||
|
||
// TODO: Salvar no cache apenas números que NÃO estavam no cache
|
||
const numbersToCache = onWhatsapp.filter((user) => {
|
||
if (!user.exists) return false;
|
||
// Verifica se estava no cache usando jidOptions
|
||
const cached = cachedNumbers?.find((cached) =>
|
||
cached.jidOptions.includes(user.jid.replace("+", ""))
|
||
);
|
||
return !cached;
|
||
});
|
||
|
||
if (numbersToCache.length > 0) {
|
||
this.logger.verbose(`Salvando ${numbersToCache.length} números no cache`);
|
||
await saveOnWhatsappCache(
|
||
numbersToCache.map((user) => ({
|
||
remoteJid: user.jid,
|
||
lid: user.lid === "lid" ? "lid" : undefined,
|
||
}))
|
||
);
|
||
}
|
||
|
||
return onWhatsapp;
|
||
}
|
||
|
||
public async markMessageAsRead(data: ReadMessageDto) {
|
||
try {
|
||
const keys: proto.IMessageKey[] = [];
|
||
data.readMessages.forEach((read) => {
|
||
if (isJidGroup(read.remoteJid) || isPnUser(read.remoteJid)) {
|
||
keys.push({
|
||
remoteJid: read.remoteJid,
|
||
fromMe: read.fromMe,
|
||
id: read.id,
|
||
});
|
||
}
|
||
});
|
||
await this.client.readMessages(keys);
|
||
return { message: "Read messages", read: "success" };
|
||
} catch (error) {
|
||
throw new InternalServerErrorException(
|
||
"Read messages fail",
|
||
error.toString()
|
||
);
|
||
}
|
||
}
|
||
|
||
public async getLastMessage(number: string) {
|
||
const where: any = {
|
||
key: { remoteJid: number },
|
||
instanceId: this.instance.id,
|
||
};
|
||
|
||
const messages = await this.prismaRepository.message.findMany({
|
||
where,
|
||
orderBy: { messageTimestamp: "desc" },
|
||
take: 1,
|
||
});
|
||
|
||
if (messages.length === 0) {
|
||
throw new NotFoundException("Messages not found");
|
||
}
|
||
|
||
let lastMessage = messages.pop();
|
||
|
||
for (const message of messages) {
|
||
if (message.messageTimestamp >= lastMessage.messageTimestamp) {
|
||
lastMessage = message;
|
||
}
|
||
}
|
||
|
||
return lastMessage as unknown as LastMessage;
|
||
}
|
||
|
||
public async archiveChat(data: ArchiveChatDto) {
|
||
try {
|
||
let last_message = data.lastMessage;
|
||
let number = data.chat;
|
||
|
||
if (!last_message && number) {
|
||
last_message = await this.getLastMessage(number);
|
||
} else {
|
||
last_message = data.lastMessage;
|
||
last_message.messageTimestamp =
|
||
last_message?.messageTimestamp ?? Date.now();
|
||
number = last_message?.key?.remoteJid;
|
||
}
|
||
|
||
if (!last_message || Object.keys(last_message).length === 0) {
|
||
throw new NotFoundException("Last message not found");
|
||
}
|
||
|
||
await this.client.chatModify(
|
||
{ archive: data.archive, lastMessages: [last_message] },
|
||
createJid(number)
|
||
);
|
||
|
||
return { chatId: number, archived: true };
|
||
} catch (error) {
|
||
throw new InternalServerErrorException({
|
||
archived: false,
|
||
message: [
|
||
"An error occurred while archiving the chat. Open a calling.",
|
||
error.toString(),
|
||
],
|
||
});
|
||
}
|
||
}
|
||
|
||
public async markChatUnread(data: MarkChatUnreadDto) {
|
||
try {
|
||
let last_message = data.lastMessage;
|
||
let number = data.chat;
|
||
|
||
if (!last_message && number) {
|
||
last_message = await this.getLastMessage(number);
|
||
} else {
|
||
last_message = data.lastMessage;
|
||
last_message.messageTimestamp =
|
||
last_message?.messageTimestamp ?? Date.now();
|
||
number = last_message?.key?.remoteJid;
|
||
}
|
||
|
||
if (!last_message || Object.keys(last_message).length === 0) {
|
||
throw new NotFoundException("Last message not found");
|
||
}
|
||
|
||
await this.client.chatModify(
|
||
{ markRead: false, lastMessages: [last_message] },
|
||
createJid(number)
|
||
);
|
||
|
||
return { chatId: number, markedChatUnread: true };
|
||
} catch (error) {
|
||
throw new InternalServerErrorException({
|
||
markedChatUnread: false,
|
||
message: [
|
||
"An error occurred while marked unread the chat. Open a calling.",
|
||
error.toString(),
|
||
],
|
||
});
|
||
}
|
||
}
|
||
|
||
public async deleteMessage(del: DeleteMessage) {
|
||
try {
|
||
const response = await this.client.sendMessage(del.remoteJid, {
|
||
delete: del,
|
||
});
|
||
if (response) {
|
||
const messageId = response.message?.protocolMessage?.key?.id;
|
||
if (messageId) {
|
||
const isLogicalDeleted =
|
||
configService.get<Database>("DATABASE").DELETE_DATA
|
||
.LOGICAL_MESSAGE_DELETE;
|
||
let message = await this.prismaRepository.message.findFirst({
|
||
where: { key: { path: ["id"], equals: messageId } },
|
||
});
|
||
if (isLogicalDeleted) {
|
||
if (!message) return response;
|
||
const existingKey =
|
||
typeof message?.key === "object" && message.key !== null
|
||
? message.key
|
||
: {};
|
||
message = await this.prismaRepository.message.update({
|
||
where: { id: message.id },
|
||
data: {
|
||
key: { ...existingKey, deleted: true },
|
||
status: "DELETED",
|
||
},
|
||
});
|
||
if (
|
||
this.configService.get<Database>("DATABASE").SAVE_DATA
|
||
.MESSAGE_UPDATE
|
||
) {
|
||
const messageUpdate: any = {
|
||
messageId: message.id,
|
||
keyId: messageId,
|
||
remoteJid: response.key.remoteJid,
|
||
fromMe: response.key.fromMe,
|
||
participant: response.key?.participant,
|
||
status: "DELETED",
|
||
instanceId: this.instanceId,
|
||
};
|
||
await this.prismaRepository.messageUpdate.create({
|
||
data: messageUpdate,
|
||
});
|
||
}
|
||
} else {
|
||
if (!message) return response;
|
||
await this.prismaRepository.message.deleteMany({
|
||
where: { id: message.id },
|
||
});
|
||
}
|
||
this.sendDataWebhook(Events.MESSAGES_DELETE, {
|
||
id: message.id,
|
||
instanceId: message.instanceId,
|
||
key: message.key,
|
||
messageType: message.messageType,
|
||
status: "DELETED",
|
||
source: message.source,
|
||
messageTimestamp: message.messageTimestamp,
|
||
pushName: message.pushName,
|
||
participant: message.participant,
|
||
message: message.message,
|
||
});
|
||
}
|
||
}
|
||
|
||
return response;
|
||
} catch (error) {
|
||
throw new InternalServerErrorException(
|
||
"Error while deleting message for everyone",
|
||
error?.toString()
|
||
);
|
||
}
|
||
}
|
||
|
||
public async mapMediaType(mediaType) {
|
||
const map = {
|
||
imageMessage: "image",
|
||
videoMessage: "video",
|
||
documentMessage: "document",
|
||
stickerMessage: "sticker",
|
||
audioMessage: "audio",
|
||
ptvMessage: "video",
|
||
};
|
||
return map[mediaType] || null;
|
||
}
|
||
|
||
public async getBase64FromMediaMessage(
|
||
data: getBase64FromMediaMessageDto,
|
||
getBuffer = false
|
||
) {
|
||
try {
|
||
const m = data?.message;
|
||
const convertToMp4 = data?.convertToMp4 ?? false;
|
||
|
||
const msg = m?.message
|
||
? m
|
||
: ((await this.getMessage(m.key, true)) as proto.IWebMessageInfo);
|
||
|
||
if (!msg) {
|
||
throw "Message not found";
|
||
}
|
||
|
||
for (const subtype of MessageSubtype) {
|
||
if (msg.message[subtype]) {
|
||
msg.message = msg.message[subtype].message;
|
||
}
|
||
}
|
||
|
||
if (
|
||
"messageContextInfo" in msg.message &&
|
||
Object.keys(msg.message).length === 1
|
||
) {
|
||
throw "The message is messageContextInfo";
|
||
}
|
||
|
||
let mediaMessage: any;
|
||
let mediaType: string;
|
||
|
||
if (msg.message?.templateMessage) {
|
||
const template =
|
||
msg.message.templateMessage.hydratedTemplate ||
|
||
msg.message.templateMessage.hydratedFourRowTemplate;
|
||
|
||
for (const type of TypeMediaMessage) {
|
||
if (template[type]) {
|
||
mediaMessage = template[type];
|
||
mediaType = type;
|
||
msg.message = {
|
||
[type]: { ...template[type], url: template[type].staticUrl },
|
||
};
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (!mediaMessage) {
|
||
throw "Template message does not contain a supported media type";
|
||
}
|
||
} else {
|
||
for (const type of TypeMediaMessage) {
|
||
mediaMessage = msg.message[type];
|
||
if (mediaMessage) {
|
||
mediaType = type;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (!mediaMessage) {
|
||
throw "The message is not of the media type";
|
||
}
|
||
}
|
||
|
||
if (typeof mediaMessage["mediaKey"] === "object") {
|
||
msg.message[mediaType].mediaKey = Uint8Array.from(
|
||
Object.values(mediaMessage["mediaKey"])
|
||
);
|
||
}
|
||
|
||
let buffer: Buffer;
|
||
|
||
try {
|
||
buffer = await downloadMediaMessage(
|
||
{ key: msg?.key, message: msg?.message },
|
||
"buffer",
|
||
{},
|
||
{
|
||
logger: P({ level: "error" }) as any,
|
||
reuploadRequest: this.client.updateMediaMessage,
|
||
}
|
||
);
|
||
} catch {
|
||
this.logger.error(
|
||
"Download Media failed, trying to retry in 5 seconds..."
|
||
);
|
||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||
const mediaType = Object.keys(msg.message).find((key) =>
|
||
key.endsWith("Message")
|
||
);
|
||
if (!mediaType)
|
||
throw new Error("Could not determine mediaType for fallback");
|
||
|
||
try {
|
||
const media = await downloadContentFromMessage(
|
||
{
|
||
mediaKey: msg.message?.[mediaType]?.mediaKey,
|
||
directPath: msg.message?.[mediaType]?.directPath,
|
||
url: `https://mmg.whatsapp.net${msg?.message?.[mediaType]?.directPath}`,
|
||
},
|
||
await this.mapMediaType(mediaType),
|
||
{}
|
||
);
|
||
const chunks = [];
|
||
for await (const chunk of media) {
|
||
chunks.push(chunk);
|
||
}
|
||
buffer = Buffer.concat(chunks);
|
||
this.logger.info(
|
||
"Download Media with downloadContentFromMessage was successful!"
|
||
);
|
||
} catch (fallbackErr) {
|
||
this.logger.error(
|
||
"Download Media with downloadContentFromMessage also failed!"
|
||
);
|
||
throw fallbackErr;
|
||
}
|
||
}
|
||
const typeMessage = getContentType(msg.message);
|
||
|
||
const ext = mimeTypes.extension(mediaMessage?.["mimetype"]);
|
||
const fileName =
|
||
mediaMessage?.["fileName"] ||
|
||
`${msg.key.id}.${ext}` ||
|
||
`${v4()}.${ext}`;
|
||
|
||
if (convertToMp4 && typeMessage === "audioMessage") {
|
||
try {
|
||
const convert = await this.processAudioMp4(buffer.toString("base64"));
|
||
|
||
if (Buffer.isBuffer(convert)) {
|
||
const result = {
|
||
mediaType,
|
||
fileName,
|
||
caption: mediaMessage["caption"],
|
||
size: {
|
||
fileLength: mediaMessage["fileLength"],
|
||
height: mediaMessage["height"],
|
||
width: mediaMessage["width"],
|
||
},
|
||
mimetype: "audio/mp4",
|
||
base64: convert.toString("base64"),
|
||
buffer: getBuffer ? convert : null,
|
||
};
|
||
|
||
return result;
|
||
}
|
||
} catch (error) {
|
||
this.logger.error("Error converting audio to mp4:");
|
||
this.logger.error(error);
|
||
throw new BadRequestException("Failed to convert audio to MP4");
|
||
}
|
||
}
|
||
|
||
return {
|
||
mediaType,
|
||
fileName,
|
||
caption: mediaMessage["caption"],
|
||
size: {
|
||
fileLength: mediaMessage["fileLength"],
|
||
height: mediaMessage["height"],
|
||
width: mediaMessage["width"],
|
||
},
|
||
mimetype: mediaMessage["mimetype"],
|
||
base64: buffer.toString("base64"),
|
||
buffer: getBuffer ? buffer : null,
|
||
};
|
||
} catch (error) {
|
||
this.logger.error("Error processing media message:");
|
||
this.logger.error(error);
|
||
throw new BadRequestException(error.toString());
|
||
}
|
||
}
|
||
|
||
public async fetchPrivacySettings() {
|
||
const privacy = await this.client.fetchPrivacySettings();
|
||
|
||
return {
|
||
readreceipts: privacy.readreceipts,
|
||
profile: privacy.profile,
|
||
status: privacy.status,
|
||
online: privacy.online,
|
||
last: privacy.last,
|
||
groupadd: privacy.groupadd,
|
||
};
|
||
}
|
||
|
||
public async updatePrivacySettings(settings: PrivacySettingDto) {
|
||
try {
|
||
await this.client.updateReadReceiptsPrivacy(settings.readreceipts);
|
||
await this.client.updateProfilePicturePrivacy(settings.profile);
|
||
await this.client.updateStatusPrivacy(settings.status);
|
||
await this.client.updateOnlinePrivacy(settings.online);
|
||
await this.client.updateLastSeenPrivacy(settings.last);
|
||
await this.client.updateGroupsAddPrivacy(settings.groupadd);
|
||
|
||
this.reloadConnection();
|
||
|
||
return {
|
||
update: "success",
|
||
data: {
|
||
readreceipts: settings.readreceipts,
|
||
profile: settings.profile,
|
||
status: settings.status,
|
||
online: settings.online,
|
||
last: settings.last,
|
||
groupadd: settings.groupadd,
|
||
},
|
||
};
|
||
} catch (error) {
|
||
throw new InternalServerErrorException(
|
||
"Error updating privacy settings",
|
||
error.toString()
|
||
);
|
||
}
|
||
}
|
||
|
||
public async fetchBusinessProfile(number: string): Promise<NumberBusiness> {
|
||
try {
|
||
const jid = number ? createJid(number) : this.instance.wuid;
|
||
|
||
const profile = await this.client.getBusinessProfile(jid);
|
||
|
||
if (!profile) {
|
||
const info = await this.whatsappNumber({ numbers: [jid] });
|
||
|
||
return {
|
||
isBusiness: false,
|
||
message: "Not is business profile",
|
||
...info?.shift(),
|
||
};
|
||
}
|
||
|
||
return { isBusiness: true, ...profile };
|
||
} catch (error) {
|
||
throw new InternalServerErrorException(
|
||
"Error updating profile name",
|
||
error.toString()
|
||
);
|
||
}
|
||
}
|
||
|
||
public async updateProfileName(name: string) {
|
||
try {
|
||
await this.client.updateProfileName(name);
|
||
|
||
return { update: "success" };
|
||
} catch (error) {
|
||
throw new InternalServerErrorException(
|
||
"Error updating profile name",
|
||
error.toString()
|
||
);
|
||
}
|
||
}
|
||
|
||
public async updateProfileStatus(status: string) {
|
||
try {
|
||
await this.client.updateProfileStatus(status);
|
||
|
||
return { update: "success" };
|
||
} catch (error) {
|
||
throw new InternalServerErrorException(
|
||
"Error updating profile status",
|
||
error.toString()
|
||
);
|
||
}
|
||
}
|
||
|
||
public async updateProfilePicture(picture: string) {
|
||
try {
|
||
let pic: WAMediaUpload;
|
||
if (isURL(picture)) {
|
||
const timestamp = new Date().getTime();
|
||
const parsedURL = new URL(picture);
|
||
parsedURL.searchParams.set("timestamp", timestamp.toString());
|
||
const url = parsedURL.toString();
|
||
|
||
let config: any = { responseType: "arraybuffer" };
|
||
|
||
if (this.localProxy?.enabled) {
|
||
config = {
|
||
...config,
|
||
httpsAgent: makeProxyAgent({
|
||
host: this.localProxy.host,
|
||
port: this.localProxy.port,
|
||
protocol: this.localProxy.protocol,
|
||
username: this.localProxy.username,
|
||
password: this.localProxy.password,
|
||
}),
|
||
};
|
||
}
|
||
|
||
pic = (await axios.get(url, config)).data;
|
||
} else if (isBase64(picture)) {
|
||
pic = Buffer.from(picture, "base64");
|
||
} else {
|
||
throw new BadRequestException(
|
||
'"profilePicture" must be a url or a base64'
|
||
);
|
||
}
|
||
|
||
await this.client.updateProfilePicture(this.instance.wuid, pic);
|
||
|
||
this.reloadConnection();
|
||
|
||
return { update: "success" };
|
||
} catch (error) {
|
||
throw new InternalServerErrorException(
|
||
"Error updating profile picture",
|
||
error.toString()
|
||
);
|
||
}
|
||
}
|
||
|
||
public async removeProfilePicture() {
|
||
try {
|
||
await this.client.removeProfilePicture(this.instance.wuid);
|
||
|
||
this.reloadConnection();
|
||
|
||
return { update: "success" };
|
||
} catch (error) {
|
||
throw new InternalServerErrorException(
|
||
"Error removing profile picture",
|
||
error.toString()
|
||
);
|
||
}
|
||
}
|
||
|
||
public async blockUser(data: BlockUserDto) {
|
||
try {
|
||
const { number } = data;
|
||
|
||
const isWA = (await this.whatsappNumber({ numbers: [number] }))?.shift();
|
||
|
||
if (
|
||
!isWA.exists &&
|
||
!isJidGroup(isWA.jid) &&
|
||
!isWA.jid.includes("@broadcast")
|
||
) {
|
||
throw new BadRequestException(isWA);
|
||
}
|
||
|
||
const sender = isWA.jid;
|
||
|
||
await this.client.updateBlockStatus(sender, data.status);
|
||
|
||
return { block: "success" };
|
||
} catch (error) {
|
||
throw new InternalServerErrorException(
|
||
"Error blocking user",
|
||
error.toString()
|
||
);
|
||
}
|
||
}
|
||
|
||
private async formatUpdateMessage(data: UpdateMessageDto) {
|
||
try {
|
||
if (!this.configService.get<Database>("DATABASE").SAVE_DATA.NEW_MESSAGE) {
|
||
return data;
|
||
}
|
||
|
||
const msg: any = await this.getMessage(data.key, true);
|
||
|
||
if (
|
||
msg?.messageType === "conversation" ||
|
||
msg?.messageType === "extendedTextMessage"
|
||
) {
|
||
return { text: data.text };
|
||
}
|
||
|
||
if (msg?.messageType === "imageMessage") {
|
||
return { image: msg?.message?.imageMessage, caption: data.text };
|
||
}
|
||
|
||
if (msg?.messageType === "videoMessage") {
|
||
return { video: msg?.message?.videoMessage, caption: data.text };
|
||
}
|
||
|
||
return null;
|
||
} catch (error) {
|
||
this.logger.error(error);
|
||
throw new BadRequestException(error.toString());
|
||
}
|
||
}
|
||
|
||
public async updateMessage(data: UpdateMessageDto) {
|
||
const jid = createJid(data.number);
|
||
|
||
const options = await this.formatUpdateMessage(data);
|
||
|
||
if (!options) {
|
||
this.logger.error("Message not compatible");
|
||
throw new BadRequestException("Message not compatible");
|
||
}
|
||
|
||
try {
|
||
const oldMessage: any = await this.getMessage(data.key, true);
|
||
if (this.configService.get<Database>("DATABASE").SAVE_DATA.NEW_MESSAGE) {
|
||
if (!oldMessage) throw new NotFoundException("Message not found");
|
||
if (oldMessage?.key?.remoteJid !== jid) {
|
||
throw new BadRequestException("RemoteJid does not match");
|
||
}
|
||
if (oldMessage?.messageTimestamp > Date.now() + 900000) {
|
||
// 15 minutes in milliseconds
|
||
throw new BadRequestException("Message is older than 15 minutes");
|
||
}
|
||
}
|
||
|
||
const messageSent = await this.client.sendMessage(jid, {
|
||
...(options as any),
|
||
edit: data.key,
|
||
});
|
||
if (messageSent) {
|
||
const editedMessage =
|
||
messageSent?.message?.protocolMessage ||
|
||
messageSent?.message?.editedMessage?.message?.protocolMessage;
|
||
|
||
if (editedMessage) {
|
||
this.sendDataWebhook(Events.SEND_MESSAGE_UPDATE, editedMessage);
|
||
if (
|
||
this.configService.get<Chatwoot>("CHATWOOT").ENABLED &&
|
||
this.localChatwoot?.enabled
|
||
)
|
||
this.chatwootService.eventWhatsapp(
|
||
"send.message.update",
|
||
{
|
||
instanceName: this.instance.name,
|
||
instanceId: this.instance.id,
|
||
},
|
||
editedMessage
|
||
);
|
||
|
||
const messageId = messageSent.message?.protocolMessage?.key?.id;
|
||
if (
|
||
messageId &&
|
||
this.configService.get<Database>("DATABASE").SAVE_DATA.NEW_MESSAGE
|
||
) {
|
||
let message = await this.prismaRepository.message.findFirst({
|
||
where: { key: { path: ["id"], equals: messageId } },
|
||
});
|
||
if (!message) throw new NotFoundException("Message not found");
|
||
|
||
if (!(message.key.valueOf() as any).fromMe) {
|
||
new BadRequestException("You cannot edit others messages");
|
||
}
|
||
if ((message.key.valueOf() as any)?.deleted) {
|
||
new BadRequestException("You cannot edit deleted messages");
|
||
}
|
||
|
||
if (
|
||
oldMessage.messageType === "conversation" ||
|
||
oldMessage.messageType === "extendedTextMessage"
|
||
) {
|
||
oldMessage.message.conversation = data.text;
|
||
} else {
|
||
oldMessage.message[oldMessage.messageType].caption = data.text;
|
||
}
|
||
message = await this.prismaRepository.message.update({
|
||
where: { id: message.id },
|
||
data: {
|
||
message: oldMessage.message,
|
||
status: "EDITED",
|
||
messageTimestamp: Math.floor(Date.now() / 1000), // Convert to int32 by dividing by 1000 to get seconds
|
||
},
|
||
});
|
||
|
||
if (
|
||
this.configService.get<Database>("DATABASE").SAVE_DATA
|
||
.MESSAGE_UPDATE
|
||
) {
|
||
const messageUpdate: any = {
|
||
messageId: message.id,
|
||
keyId: messageId,
|
||
remoteJid: messageSent.key.remoteJid,
|
||
fromMe: messageSent.key.fromMe,
|
||
participant: messageSent.key?.participant,
|
||
status: "EDITED",
|
||
instanceId: this.instanceId,
|
||
};
|
||
await this.prismaRepository.messageUpdate.create({
|
||
data: messageUpdate,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return messageSent;
|
||
} catch (error) {
|
||
this.logger.error(error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
public async fetchLabels(): Promise<LabelDto[]> {
|
||
const labels = await this.prismaRepository.label.findMany({
|
||
where: { instanceId: this.instanceId },
|
||
});
|
||
|
||
return labels.map((label) => ({
|
||
color: label.color,
|
||
name: label.name,
|
||
id: label.labelId,
|
||
predefinedId: label.predefinedId,
|
||
}));
|
||
}
|
||
|
||
public async handleLabel(data: HandleLabelDto) {
|
||
const whatsappContact = await this.whatsappNumber({
|
||
numbers: [data.number],
|
||
});
|
||
if (whatsappContact.length === 0) {
|
||
throw new NotFoundException("Number not found");
|
||
}
|
||
const contact = whatsappContact[0];
|
||
if (!contact.exists) {
|
||
throw new NotFoundException("Number is not on WhatsApp");
|
||
}
|
||
|
||
try {
|
||
if (data.action === "add") {
|
||
await this.client.addChatLabel(contact.jid, data.labelId);
|
||
await this.addLabel(data.labelId, this.instanceId, contact.jid);
|
||
|
||
return { numberJid: contact.jid, labelId: data.labelId, add: true };
|
||
}
|
||
if (data.action === "remove") {
|
||
await this.client.removeChatLabel(contact.jid, data.labelId);
|
||
await this.removeLabel(data.labelId, this.instanceId, contact.jid);
|
||
|
||
return { numberJid: contact.jid, labelId: data.labelId, remove: true };
|
||
}
|
||
} catch (error) {
|
||
throw new BadRequestException(
|
||
`Unable to ${data.action} label to chat`,
|
||
error.toString()
|
||
);
|
||
}
|
||
}
|
||
|
||
// Group
|
||
private async updateGroupMetadataCache(groupJid: string) {
|
||
try {
|
||
const meta = await this.client.groupMetadata(groupJid);
|
||
|
||
const cacheConf = this.configService.get<CacheConf>("CACHE");
|
||
|
||
if (
|
||
(cacheConf?.REDIS?.ENABLED && cacheConf?.REDIS?.URI !== "") ||
|
||
cacheConf?.LOCAL?.ENABLED
|
||
) {
|
||
this.logger.verbose(`Updating cache for group: ${groupJid}`);
|
||
await groupMetadataCache.set(groupJid, {
|
||
timestamp: Date.now(),
|
||
data: meta,
|
||
});
|
||
}
|
||
|
||
return meta;
|
||
} catch (error) {
|
||
this.logger.error(error);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
private getGroupMetadataCache = async (groupJid: string) => {
|
||
if (!isJidGroup(groupJid)) return null;
|
||
|
||
const cacheConf = this.configService.get<CacheConf>("CACHE");
|
||
|
||
if (
|
||
(cacheConf?.REDIS?.ENABLED && cacheConf?.REDIS?.URI !== "") ||
|
||
cacheConf?.LOCAL?.ENABLED
|
||
) {
|
||
if (await groupMetadataCache?.has(groupJid)) {
|
||
console.log(`Cache request for group: ${groupJid}`);
|
||
const meta = await groupMetadataCache.get(groupJid);
|
||
|
||
if (Date.now() - meta.timestamp > 3600000) {
|
||
await this.updateGroupMetadataCache(groupJid);
|
||
}
|
||
|
||
return meta.data;
|
||
}
|
||
|
||
console.log(`Cache request for group: ${groupJid} - not found`);
|
||
return await this.updateGroupMetadataCache(groupJid);
|
||
}
|
||
|
||
return await this.findGroup({ groupJid }, "inner");
|
||
};
|
||
|
||
public async createGroup(create: CreateGroupDto) {
|
||
try {
|
||
const participants = (
|
||
await this.whatsappNumber({ numbers: create.participants })
|
||
)
|
||
.filter((participant) => participant.exists)
|
||
.map((participant) => participant.jid);
|
||
const { id } = await this.client.groupCreate(
|
||
create.subject,
|
||
participants
|
||
);
|
||
|
||
if (create?.description) {
|
||
await this.client.groupUpdateDescription(id, create.description);
|
||
}
|
||
|
||
if (create?.promoteParticipants) {
|
||
await this.updateGParticipant({
|
||
groupJid: id,
|
||
action: "promote",
|
||
participants: participants,
|
||
});
|
||
}
|
||
|
||
const group = await this.client.groupMetadata(id);
|
||
|
||
return group;
|
||
} catch (error) {
|
||
this.logger.error(error);
|
||
throw new InternalServerErrorException(
|
||
"Error creating group",
|
||
error.toString()
|
||
);
|
||
}
|
||
}
|
||
|
||
public async updateGroupPicture(picture: GroupPictureDto) {
|
||
try {
|
||
let pic: WAMediaUpload;
|
||
if (isURL(picture.image)) {
|
||
const timestamp = new Date().getTime();
|
||
const parsedURL = new URL(picture.image);
|
||
parsedURL.searchParams.set("timestamp", timestamp.toString());
|
||
const url = parsedURL.toString();
|
||
|
||
let config: any = { responseType: "arraybuffer" };
|
||
|
||
if (this.localProxy?.enabled) {
|
||
config = {
|
||
...config,
|
||
httpsAgent: makeProxyAgent({
|
||
host: this.localProxy.host,
|
||
port: this.localProxy.port,
|
||
protocol: this.localProxy.protocol,
|
||
username: this.localProxy.username,
|
||
password: this.localProxy.password,
|
||
}),
|
||
};
|
||
}
|
||
|
||
pic = (await axios.get(url, config)).data;
|
||
} else if (isBase64(picture.image)) {
|
||
pic = Buffer.from(picture.image, "base64");
|
||
} else {
|
||
throw new BadRequestException(
|
||
'"profilePicture" must be a url or a base64'
|
||
);
|
||
}
|
||
await this.client.updateProfilePicture(picture.groupJid, pic);
|
||
|
||
return { update: "success" };
|
||
} catch (error) {
|
||
throw new InternalServerErrorException(
|
||
"Error update group picture",
|
||
error.toString()
|
||
);
|
||
}
|
||
}
|
||
|
||
public async updateGroupSubject(data: GroupSubjectDto) {
|
||
try {
|
||
await this.client.groupUpdateSubject(data.groupJid, data.subject);
|
||
|
||
return { update: "success" };
|
||
} catch (error) {
|
||
throw new InternalServerErrorException(
|
||
"Error updating group subject",
|
||
error.toString()
|
||
);
|
||
}
|
||
}
|
||
|
||
public async updateGroupDescription(data: GroupDescriptionDto) {
|
||
try {
|
||
await this.client.groupUpdateDescription(data.groupJid, data.description);
|
||
|
||
return { update: "success" };
|
||
} catch (error) {
|
||
throw new InternalServerErrorException(
|
||
"Error updating group description",
|
||
error.toString()
|
||
);
|
||
}
|
||
}
|
||
|
||
public async findGroup(id: GroupJid, reply: "inner" | "out" = "out") {
|
||
try {
|
||
const group = await this.client.groupMetadata(id.groupJid);
|
||
|
||
if (!group) {
|
||
this.logger.error("Group not found");
|
||
return null;
|
||
}
|
||
|
||
const picture = await this.profilePicture(group.id);
|
||
|
||
return {
|
||
id: group.id,
|
||
subject: group.subject,
|
||
subjectOwner: group.subjectOwner,
|
||
subjectTime: group.subjectTime,
|
||
pictureUrl: picture.profilePictureUrl,
|
||
size: group.participants.length,
|
||
creation: group.creation,
|
||
owner: group.owner,
|
||
desc: group.desc,
|
||
descId: group.descId,
|
||
restrict: group.restrict,
|
||
announce: group.announce,
|
||
participants: group.participants,
|
||
isCommunity: group.isCommunity,
|
||
isCommunityAnnounce: group.isCommunityAnnounce,
|
||
linkedParent: group.linkedParent,
|
||
};
|
||
} catch (error) {
|
||
if (reply === "inner") {
|
||
return;
|
||
}
|
||
throw new NotFoundException("Error fetching group", error.toString());
|
||
}
|
||
}
|
||
|
||
public async fetchAllGroups(getParticipants: GetParticipant) {
|
||
const fetch = Object.values(
|
||
await this?.client?.groupFetchAllParticipating()
|
||
);
|
||
|
||
let groups = [];
|
||
for (const group of fetch) {
|
||
const picture = await this.profilePicture(group.id);
|
||
|
||
const result = {
|
||
id: group.id,
|
||
subject: group.subject,
|
||
subjectOwner: group.subjectOwner,
|
||
subjectTime: group.subjectTime,
|
||
pictureUrl: picture?.profilePictureUrl,
|
||
size: group.participants.length,
|
||
creation: group.creation,
|
||
owner: group.owner,
|
||
desc: group.desc,
|
||
descId: group.descId,
|
||
restrict: group.restrict,
|
||
announce: group.announce,
|
||
isCommunity: group.isCommunity,
|
||
isCommunityAnnounce: group.isCommunityAnnounce,
|
||
linkedParent: group.linkedParent,
|
||
};
|
||
|
||
if (getParticipants.getParticipants == "true") {
|
||
result["participants"] = group.participants;
|
||
}
|
||
|
||
groups = [...groups, result];
|
||
}
|
||
|
||
return groups;
|
||
}
|
||
|
||
public async inviteCode(id: GroupJid) {
|
||
try {
|
||
const code = await this.client.groupInviteCode(id.groupJid);
|
||
return {
|
||
inviteUrl: `https://chat.whatsapp.com/${code}`,
|
||
inviteCode: code,
|
||
};
|
||
} catch (error) {
|
||
throw new NotFoundException("No invite code", error.toString());
|
||
}
|
||
}
|
||
|
||
public async inviteInfo(id: GroupInvite) {
|
||
try {
|
||
return await this.client.groupGetInviteInfo(id.inviteCode);
|
||
} catch {
|
||
throw new NotFoundException("No invite info", id.inviteCode);
|
||
}
|
||
}
|
||
|
||
public async sendInvite(id: GroupSendInvite) {
|
||
try {
|
||
const inviteCode = await this.inviteCode({ groupJid: id.groupJid });
|
||
|
||
const inviteUrl = inviteCode.inviteUrl;
|
||
|
||
const numbers = id.numbers.map((number) => createJid(number));
|
||
const description = id.description ?? "";
|
||
|
||
const msg = `${description}\n\n${inviteUrl}`;
|
||
|
||
const message = { conversation: msg };
|
||
|
||
for await (const number of numbers) {
|
||
await this.sendMessageWithTyping(number, message);
|
||
}
|
||
|
||
return { send: true, inviteUrl };
|
||
} catch {
|
||
throw new NotFoundException("No send invite");
|
||
}
|
||
}
|
||
|
||
public async acceptInviteCode(id: AcceptGroupInvite) {
|
||
try {
|
||
const groupJid = await this.client.groupAcceptInvite(id.inviteCode);
|
||
return { accepted: true, groupJid: groupJid };
|
||
} catch (error) {
|
||
throw new NotFoundException("Accept invite error", error.toString());
|
||
}
|
||
}
|
||
|
||
public async revokeInviteCode(id: GroupJid) {
|
||
try {
|
||
const inviteCode = await this.client.groupRevokeInvite(id.groupJid);
|
||
return { revoked: true, inviteCode };
|
||
} catch (error) {
|
||
throw new NotFoundException("Revoke error", error.toString());
|
||
}
|
||
}
|
||
|
||
public async findParticipants(id: GroupJid) {
|
||
try {
|
||
const participants = (await this.client.groupMetadata(id.groupJid))
|
||
.participants;
|
||
const contacts = await this.prismaRepository.contact.findMany({
|
||
where: {
|
||
instanceId: this.instanceId,
|
||
remoteJid: { in: participants.map((p) => p.id) },
|
||
},
|
||
});
|
||
const parsedParticipants = participants.map((participant) => {
|
||
const contact = contacts.find((c) => c.remoteJid === participant.id);
|
||
return {
|
||
...participant,
|
||
name: participant.name ?? contact?.pushName,
|
||
imgUrl: participant.imgUrl ?? contact?.profilePicUrl,
|
||
};
|
||
});
|
||
|
||
const usersContacts = parsedParticipants.filter((c) =>
|
||
c.id.includes("@s.whatsapp")
|
||
);
|
||
if (usersContacts) {
|
||
await saveOnWhatsappCache(
|
||
usersContacts.map((c) => ({ remoteJid: c.id }))
|
||
);
|
||
}
|
||
|
||
return { participants: parsedParticipants };
|
||
} catch (error) {
|
||
console.error(error);
|
||
throw new NotFoundException("No participants", error.toString());
|
||
}
|
||
}
|
||
|
||
public async updateGParticipant(update: GroupUpdateParticipantDto) {
|
||
try {
|
||
const participants = update.participants.map((p) => createJid(p));
|
||
const updateParticipants = await this.client.groupParticipantsUpdate(
|
||
update.groupJid,
|
||
participants,
|
||
update.action
|
||
);
|
||
return { updateParticipants: updateParticipants };
|
||
} catch (error) {
|
||
throw new BadRequestException(
|
||
"Error updating participants",
|
||
error.toString()
|
||
);
|
||
}
|
||
}
|
||
|
||
public async updateGSetting(update: GroupUpdateSettingDto) {
|
||
try {
|
||
const updateSetting = await this.client.groupSettingUpdate(
|
||
update.groupJid,
|
||
update.action
|
||
);
|
||
return { updateSetting: updateSetting };
|
||
} catch (error) {
|
||
throw new BadRequestException("Error updating setting", error.toString());
|
||
}
|
||
}
|
||
|
||
public async toggleEphemeral(update: GroupToggleEphemeralDto) {
|
||
try {
|
||
await this.client.groupToggleEphemeral(
|
||
update.groupJid,
|
||
update.expiration
|
||
);
|
||
return { success: true };
|
||
} catch (error) {
|
||
throw new BadRequestException("Error updating setting", error.toString());
|
||
}
|
||
}
|
||
|
||
public async leaveGroup(id: GroupJid) {
|
||
try {
|
||
await this.client.groupLeave(id.groupJid);
|
||
return { groupJid: id.groupJid, leave: true };
|
||
} catch (error) {
|
||
throw new BadRequestException(
|
||
"Unable to leave the group",
|
||
error.toString()
|
||
);
|
||
}
|
||
}
|
||
|
||
public async templateMessage() {
|
||
throw new Error("Method not available in the Baileys service");
|
||
}
|
||
|
||
private deserializeMessageBuffers(obj: any): any {
|
||
if (obj === null || obj === undefined) {
|
||
return obj;
|
||
}
|
||
|
||
if (
|
||
typeof obj === "object" &&
|
||
!Array.isArray(obj) &&
|
||
!Buffer.isBuffer(obj)
|
||
) {
|
||
const keys = Object.keys(obj);
|
||
const isIndexedObject = keys.every((key) => !isNaN(Number(key)));
|
||
|
||
if (isIndexedObject && keys.length > 0) {
|
||
const values = keys
|
||
.sort((a, b) => Number(a) - Number(b))
|
||
.map((key) => obj[key]);
|
||
return new Uint8Array(values);
|
||
}
|
||
}
|
||
|
||
// Is Buffer?, converter to Uint8Array
|
||
if (Buffer.isBuffer(obj)) {
|
||
return new Uint8Array(obj);
|
||
}
|
||
|
||
// Process arrays recursively
|
||
if (Array.isArray(obj)) {
|
||
return obj.map((item) => this.deserializeMessageBuffers(item));
|
||
}
|
||
|
||
// Process objects recursively
|
||
if (typeof obj === "object") {
|
||
const converted: any = {};
|
||
for (const key in obj) {
|
||
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
||
converted[key] = this.deserializeMessageBuffers(obj[key]);
|
||
}
|
||
}
|
||
return converted;
|
||
}
|
||
|
||
return obj;
|
||
}
|
||
|
||
private prepareMessage(message: proto.IWebMessageInfo): any {
|
||
const contentType = getContentType(message.message);
|
||
const contentMsg = message?.message[contentType] as any;
|
||
|
||
const messageRaw = {
|
||
key: message.key, // Save key exactly as it comes from Baileys
|
||
pushName:
|
||
message.pushName ||
|
||
(message.key.fromMe
|
||
? "Você"
|
||
: message?.participant ||
|
||
(message.key?.participant
|
||
? message.key.participant.split("@")[0]
|
||
: null)),
|
||
status: status[message.status],
|
||
message: this.deserializeMessageBuffers({ ...message.message }),
|
||
contextInfo: this.deserializeMessageBuffers(contentMsg?.contextInfo),
|
||
messageType: contentType || "unknown",
|
||
messageTimestamp: Long.isLong(message.messageTimestamp)
|
||
? message.messageTimestamp.toNumber()
|
||
: (message.messageTimestamp as number),
|
||
instanceId: this.instanceId,
|
||
source: getDevice(message.key.id),
|
||
};
|
||
|
||
if (!messageRaw.status && message.key.fromMe === false) {
|
||
messageRaw.status = status[3]; // DELIVERED MESSAGE
|
||
}
|
||
|
||
if (messageRaw.message.extendedTextMessage) {
|
||
messageRaw.messageType = "conversation";
|
||
messageRaw.message.conversation =
|
||
messageRaw.message.extendedTextMessage.text;
|
||
delete messageRaw.message.extendedTextMessage;
|
||
}
|
||
|
||
if (messageRaw.message.documentWithCaptionMessage) {
|
||
messageRaw.messageType = "documentMessage";
|
||
messageRaw.message.documentMessage =
|
||
messageRaw.message.documentWithCaptionMessage.message.documentMessage;
|
||
delete messageRaw.message.documentWithCaptionMessage;
|
||
}
|
||
|
||
const quotedMessage = messageRaw?.contextInfo?.quotedMessage;
|
||
if (quotedMessage) {
|
||
if (quotedMessage.extendedTextMessage) {
|
||
quotedMessage.conversation = quotedMessage.extendedTextMessage.text;
|
||
delete quotedMessage.extendedTextMessage;
|
||
}
|
||
|
||
if (quotedMessage.documentWithCaptionMessage) {
|
||
quotedMessage.documentMessage =
|
||
quotedMessage.documentWithCaptionMessage.message.documentMessage;
|
||
delete quotedMessage.documentWithCaptionMessage;
|
||
}
|
||
}
|
||
|
||
return messageRaw;
|
||
}
|
||
|
||
private async syncChatwootLostMessages() {
|
||
if (
|
||
this.configService.get<Chatwoot>("CHATWOOT").ENABLED &&
|
||
this.localChatwoot?.enabled
|
||
) {
|
||
const chatwootConfig = await this.findChatwoot();
|
||
const prepare = (message: any) => this.prepareMessage(message);
|
||
this.chatwootService.syncLostMessages(
|
||
{ instanceName: this.instance.name },
|
||
chatwootConfig,
|
||
prepare
|
||
);
|
||
|
||
// Generate ID for this cron task and store in cache
|
||
const cronId = cuid();
|
||
const cronKey = `chatwoot:syncLostMessages`;
|
||
await this.chatwootService
|
||
.getCache()
|
||
?.hSet(cronKey, this.instance.name, cronId);
|
||
|
||
const task = cron.schedule("0,30 * * * *", async () => {
|
||
// Check ID before executing (only if cache is available)
|
||
const cache = this.chatwootService.getCache();
|
||
if (cache) {
|
||
const storedId = await cache.hGet(cronKey, this.instance.name);
|
||
if (storedId && storedId !== cronId) {
|
||
this.logger.info(
|
||
`Stopping syncChatwootLostMessages cron - ID mismatch: ${cronId} vs ${storedId}`
|
||
);
|
||
task.stop();
|
||
return;
|
||
}
|
||
}
|
||
this.chatwootService.syncLostMessages(
|
||
{ instanceName: this.instance.name },
|
||
chatwootConfig,
|
||
prepare
|
||
);
|
||
});
|
||
task.start();
|
||
}
|
||
}
|
||
|
||
private async updateMessagesReadedByTimestamp(
|
||
remoteJid: string,
|
||
timestamp?: number
|
||
): Promise<number> {
|
||
if (timestamp === undefined || timestamp === null) return 0;
|
||
|
||
// Use raw SQL to avoid JSON path issues
|
||
const result = await this.prismaRepository.$executeRaw`
|
||
UPDATE "Message"
|
||
SET "status" = ${status[4]}
|
||
WHERE "instanceId" = ${this.instanceId}
|
||
AND "key"->>'remoteJid' = ${remoteJid}
|
||
AND ("key"->>'fromMe')::boolean = false
|
||
AND "messageTimestamp" <= ${timestamp}
|
||
AND ("status" IS NULL OR "status" = ${status[3]})
|
||
`;
|
||
|
||
if (result) {
|
||
if (result > 0) {
|
||
this.updateChatUnreadMessages(remoteJid);
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
return 0;
|
||
}
|
||
|
||
private async updateChatUnreadMessages(remoteJid: string): Promise<number> {
|
||
const [chat, unreadMessages] = await Promise.all([
|
||
this.prismaRepository.chat.findFirst({ where: { remoteJid } }),
|
||
// Use raw SQL to avoid JSON path issues
|
||
this.prismaRepository.$queryRaw`
|
||
SELECT COUNT(*)::int as count FROM "Message"
|
||
WHERE "instanceId" = ${this.instanceId}
|
||
AND "key"->>'remoteJid' = ${remoteJid}
|
||
AND ("key"->>'fromMe')::boolean = false
|
||
AND "status" = ${status[3]}
|
||
`.then((result: any[]) => result[0]?.count || 0),
|
||
]);
|
||
|
||
if (chat && chat.unreadMessages !== unreadMessages) {
|
||
await this.prismaRepository.chat.update({
|
||
where: { id: chat.id },
|
||
data: { unreadMessages },
|
||
});
|
||
}
|
||
|
||
return unreadMessages;
|
||
}
|
||
|
||
private async addLabel(labelId: string, instanceId: string, chatId: string) {
|
||
const id = cuid();
|
||
|
||
await this.prismaRepository.$executeRawUnsafe(
|
||
`INSERT INTO "Chat" ("id", "instanceId", "remoteJid", "labels", "createdAt", "updatedAt")
|
||
VALUES ($4, $2, $3, to_jsonb(ARRAY[$1]::text[]), NOW(), NOW()) ON CONFLICT ("instanceId", "remoteJid")
|
||
DO
|
||
UPDATE
|
||
SET "labels" = (
|
||
SELECT to_jsonb(array_agg(DISTINCT elem))
|
||
FROM (
|
||
SELECT jsonb_array_elements_text("Chat"."labels") AS elem
|
||
UNION
|
||
SELECT $1::text AS elem
|
||
) sub
|
||
),
|
||
"updatedAt" = NOW();`,
|
||
labelId,
|
||
instanceId,
|
||
chatId,
|
||
id
|
||
);
|
||
}
|
||
|
||
private async removeLabel(
|
||
labelId: string,
|
||
instanceId: string,
|
||
chatId: string
|
||
) {
|
||
const id = cuid();
|
||
|
||
await this.prismaRepository.$executeRawUnsafe(
|
||
`INSERT INTO "Chat" ("id", "instanceId", "remoteJid", "labels", "createdAt", "updatedAt")
|
||
VALUES ($4, $2, $3, '[]'::jsonb, NOW(), NOW()) ON CONFLICT ("instanceId", "remoteJid")
|
||
DO
|
||
UPDATE
|
||
SET "labels" = COALESCE (
|
||
(
|
||
SELECT jsonb_agg(elem)
|
||
FROM jsonb_array_elements_text("Chat"."labels") AS elem
|
||
WHERE elem <> $1
|
||
),
|
||
'[]'::jsonb
|
||
),
|
||
"updatedAt" = NOW();`,
|
||
labelId,
|
||
instanceId,
|
||
chatId,
|
||
id
|
||
);
|
||
}
|
||
|
||
public async baileysOnWhatsapp(jid: string) {
|
||
const response = await this.client.onWhatsApp(jid);
|
||
|
||
return response;
|
||
}
|
||
|
||
public async baileysProfilePictureUrl(
|
||
jid: string,
|
||
type: "image" | "preview",
|
||
timeoutMs: number
|
||
) {
|
||
const response = await this.client.profilePictureUrl(jid, type, timeoutMs);
|
||
|
||
return response;
|
||
}
|
||
|
||
public async baileysAssertSessions(jids: string[]) {
|
||
const response = await this.client.assertSessions(jids);
|
||
|
||
return response;
|
||
}
|
||
|
||
public async baileysCreateParticipantNodes(
|
||
jids: string[],
|
||
message: proto.IMessage,
|
||
extraAttrs: any
|
||
) {
|
||
const response = await this.client.createParticipantNodes(
|
||
jids,
|
||
message,
|
||
extraAttrs
|
||
);
|
||
|
||
const convertedResponse = {
|
||
...response,
|
||
nodes: response.nodes.map((node: any) => ({
|
||
...node,
|
||
content: node.content?.map((c: any) => ({
|
||
...c,
|
||
content:
|
||
c.content instanceof Uint8Array
|
||
? Buffer.from(c.content).toString("base64")
|
||
: c.content,
|
||
})),
|
||
})),
|
||
};
|
||
|
||
return convertedResponse;
|
||
}
|
||
|
||
public async baileysSendNode(stanza: any) {
|
||
console.log("stanza", JSON.stringify(stanza));
|
||
const response = await this.client.sendNode(stanza);
|
||
|
||
return response;
|
||
}
|
||
|
||
public async baileysGetUSyncDevices(
|
||
jids: string[],
|
||
useCache: boolean,
|
||
ignoreZeroDevices: boolean
|
||
) {
|
||
const response = await this.client.getUSyncDevices(
|
||
jids,
|
||
useCache,
|
||
ignoreZeroDevices
|
||
);
|
||
|
||
return response;
|
||
}
|
||
|
||
public async baileysGenerateMessageTag() {
|
||
const response = await this.client.generateMessageTag();
|
||
|
||
return response;
|
||
}
|
||
|
||
public async baileysSignalRepositoryDecryptMessage(
|
||
jid: string,
|
||
type: "pkmsg" | "msg",
|
||
ciphertext: string
|
||
) {
|
||
try {
|
||
const ciphertextBuffer = Buffer.from(ciphertext, "base64");
|
||
|
||
const response = await this.client.signalRepository.decryptMessage({
|
||
jid,
|
||
type,
|
||
ciphertext: ciphertextBuffer,
|
||
});
|
||
|
||
return response instanceof Uint8Array
|
||
? Buffer.from(response).toString("base64")
|
||
: response;
|
||
} catch (error) {
|
||
this.logger.error("Error decrypting message:");
|
||
this.logger.error(error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
public async baileysGetAuthState() {
|
||
const response = {
|
||
me: this.client.authState.creds.me,
|
||
account: this.client.authState.creds.account,
|
||
};
|
||
|
||
return response;
|
||
}
|
||
|
||
//Business Controller
|
||
public async fetchCatalog(instanceName: string, data: getCollectionsDto) {
|
||
const jid = data.number ? createJid(data.number) : this.client?.user?.id;
|
||
const limit = data.limit || 10;
|
||
const cursor = null;
|
||
|
||
const onWhatsapp = (await this.whatsappNumber({ numbers: [jid] }))?.shift();
|
||
|
||
if (!onWhatsapp.exists) {
|
||
throw new BadRequestException(onWhatsapp);
|
||
}
|
||
|
||
try {
|
||
const info = (await this.whatsappNumber({ numbers: [jid] }))?.shift();
|
||
const business = await this.fetchBusinessProfile(info?.jid);
|
||
|
||
let catalog = await this.getCatalog({ jid: info?.jid, limit, cursor });
|
||
let nextPageCursor = catalog.nextPageCursor;
|
||
let nextPageCursorJson = nextPageCursor
|
||
? JSON.parse(atob(nextPageCursor))
|
||
: null;
|
||
let pagination = nextPageCursorJson?.pagination_cursor
|
||
? JSON.parse(atob(nextPageCursorJson.pagination_cursor))
|
||
: null;
|
||
let fetcherHasMore = pagination?.fetcher_has_more === true ? true : false;
|
||
|
||
let productsCatalog = catalog.products || [];
|
||
let countLoops = 0;
|
||
while (fetcherHasMore && countLoops < 4) {
|
||
catalog = await this.getCatalog({
|
||
jid: info?.jid,
|
||
limit,
|
||
cursor: nextPageCursor,
|
||
});
|
||
nextPageCursor = catalog.nextPageCursor;
|
||
nextPageCursorJson = nextPageCursor
|
||
? JSON.parse(atob(nextPageCursor))
|
||
: null;
|
||
pagination = nextPageCursorJson?.pagination_cursor
|
||
? JSON.parse(atob(nextPageCursorJson.pagination_cursor))
|
||
: null;
|
||
fetcherHasMore = pagination?.fetcher_has_more === true ? true : false;
|
||
productsCatalog = [...productsCatalog, ...catalog.products];
|
||
countLoops++;
|
||
}
|
||
|
||
return {
|
||
wuid: info?.jid || jid,
|
||
numberExists: info?.exists,
|
||
isBusiness: business.isBusiness,
|
||
catalogLength: productsCatalog.length,
|
||
catalog: productsCatalog,
|
||
};
|
||
} catch (error) {
|
||
console.log(error);
|
||
return { wuid: jid, name: null, isBusiness: false };
|
||
}
|
||
}
|
||
|
||
public async getCatalog({ jid, limit, cursor }: GetCatalogOptions): Promise<{
|
||
products: Product[];
|
||
nextPageCursor: string | undefined;
|
||
}> {
|
||
try {
|
||
jid = jid ? createJid(jid) : this.instance.wuid;
|
||
|
||
const catalog = await this.client.getCatalog({
|
||
jid,
|
||
limit: limit,
|
||
cursor: cursor,
|
||
});
|
||
|
||
if (!catalog) {
|
||
return { products: undefined, nextPageCursor: undefined };
|
||
}
|
||
|
||
return catalog;
|
||
} catch (error) {
|
||
throw new InternalServerErrorException(
|
||
"Error getCatalog",
|
||
error.toString()
|
||
);
|
||
}
|
||
}
|
||
|
||
public async fetchCollections(instanceName: string, data: getCollectionsDto) {
|
||
const jid = data.number ? createJid(data.number) : this.client?.user?.id;
|
||
const limit = data.limit <= 20 ? data.limit : 20; //(tem esse limite, não sei porque)
|
||
|
||
const onWhatsapp = (await this.whatsappNumber({ numbers: [jid] }))?.shift();
|
||
|
||
if (!onWhatsapp.exists) {
|
||
throw new BadRequestException(onWhatsapp);
|
||
}
|
||
|
||
try {
|
||
const info = (await this.whatsappNumber({ numbers: [jid] }))?.shift();
|
||
const business = await this.fetchBusinessProfile(info?.jid);
|
||
const collections = await this.getCollections(info?.jid, limit);
|
||
|
||
return {
|
||
wuid: info?.jid || jid,
|
||
name: info?.name,
|
||
numberExists: info?.exists,
|
||
isBusiness: business.isBusiness,
|
||
collectionsLength: collections?.length,
|
||
collections: collections,
|
||
};
|
||
} catch {
|
||
return { wuid: jid, name: null, isBusiness: false };
|
||
}
|
||
}
|
||
|
||
public async getCollections(
|
||
jid?: string | undefined,
|
||
limit?: number
|
||
): Promise<CatalogCollection[]> {
|
||
try {
|
||
jid = jid ? createJid(jid) : this.instance.wuid;
|
||
|
||
const result = await this.client.getCollections(jid, limit);
|
||
|
||
if (!result) {
|
||
return [
|
||
{ id: undefined, name: undefined, products: [], status: undefined },
|
||
];
|
||
}
|
||
|
||
return result.collections;
|
||
} catch (error) {
|
||
throw new InternalServerErrorException(
|
||
"Error getCatalog",
|
||
error.toString()
|
||
);
|
||
}
|
||
}
|
||
|
||
public async fetchMessages(query: Query<Message>) {
|
||
const keyFilters = query?.where?.key as ExtendedIMessageKey;
|
||
|
||
const timestampFilter = {};
|
||
if (query?.where?.messageTimestamp) {
|
||
if (
|
||
query.where.messageTimestamp["gte"] &&
|
||
query.where.messageTimestamp["lte"]
|
||
) {
|
||
timestampFilter["messageTimestamp"] = {
|
||
gte: Math.floor(
|
||
new Date(query.where.messageTimestamp["gte"]).getTime() / 1000
|
||
),
|
||
lte: Math.floor(
|
||
new Date(query.where.messageTimestamp["lte"]).getTime() / 1000
|
||
),
|
||
};
|
||
}
|
||
}
|
||
|
||
const count = await this.prismaRepository.message.count({
|
||
where: {
|
||
instanceId: this.instanceId,
|
||
id: query?.where?.id,
|
||
source: query?.where?.source,
|
||
messageType: query?.where?.messageType,
|
||
...timestampFilter,
|
||
AND: [
|
||
keyFilters?.id
|
||
? { key: { path: ["id"], equals: keyFilters?.id } }
|
||
: {},
|
||
keyFilters?.fromMe
|
||
? { key: { path: ["fromMe"], equals: keyFilters?.fromMe } }
|
||
: {},
|
||
keyFilters?.remoteJid
|
||
? { key: { path: ["remoteJid"], equals: keyFilters?.remoteJid } }
|
||
: {},
|
||
keyFilters?.participant
|
||
? {
|
||
key: { path: ["participant"], equals: keyFilters?.participant },
|
||
}
|
||
: {},
|
||
{
|
||
OR: [
|
||
keyFilters?.remoteJid
|
||
? {
|
||
key: { path: ["remoteJid"], equals: keyFilters?.remoteJid },
|
||
}
|
||
: {},
|
||
keyFilters?.remoteJidAlt
|
||
? {
|
||
key: {
|
||
path: ["remoteJidAlt"],
|
||
equals: keyFilters?.remoteJidAlt,
|
||
},
|
||
}
|
||
: {},
|
||
],
|
||
},
|
||
],
|
||
},
|
||
});
|
||
|
||
if (!query?.offset) {
|
||
query.offset = 50;
|
||
}
|
||
|
||
if (!query?.page) {
|
||
query.page = 1;
|
||
}
|
||
|
||
const messages = await this.prismaRepository.message.findMany({
|
||
where: {
|
||
instanceId: this.instanceId,
|
||
id: query?.where?.id,
|
||
source: query?.where?.source,
|
||
messageType: query?.where?.messageType,
|
||
...timestampFilter,
|
||
AND: [
|
||
keyFilters?.id
|
||
? { key: { path: ["id"], equals: keyFilters?.id } }
|
||
: {},
|
||
keyFilters?.fromMe
|
||
? { key: { path: ["fromMe"], equals: keyFilters?.fromMe } }
|
||
: {},
|
||
keyFilters?.remoteJid
|
||
? { key: { path: ["remoteJid"], equals: keyFilters?.remoteJid } }
|
||
: {},
|
||
keyFilters?.participant
|
||
? {
|
||
key: { path: ["participant"], equals: keyFilters?.participant },
|
||
}
|
||
: {},
|
||
{
|
||
OR: [
|
||
keyFilters?.remoteJid
|
||
? {
|
||
key: { path: ["remoteJid"], equals: keyFilters?.remoteJid },
|
||
}
|
||
: {},
|
||
keyFilters?.remoteJidAlt
|
||
? {
|
||
key: {
|
||
path: ["remoteJidAlt"],
|
||
equals: keyFilters?.remoteJidAlt,
|
||
},
|
||
}
|
||
: {},
|
||
],
|
||
},
|
||
],
|
||
},
|
||
orderBy: { messageTimestamp: "desc" },
|
||
skip:
|
||
query.offset * (query?.page === 1 ? 0 : (query?.page as number) - 1),
|
||
take: query.offset,
|
||
select: {
|
||
id: true,
|
||
key: true,
|
||
pushName: true,
|
||
messageType: true,
|
||
message: true,
|
||
messageTimestamp: true,
|
||
instanceId: true,
|
||
source: true,
|
||
contextInfo: true,
|
||
MessageUpdate: { select: { status: true } },
|
||
},
|
||
});
|
||
|
||
const formattedMessages = messages.map((message) => {
|
||
const messageKey = message.key as {
|
||
fromMe: boolean;
|
||
remoteJid: string;
|
||
id: string;
|
||
participant?: string;
|
||
};
|
||
|
||
if (!message.pushName) {
|
||
if (messageKey.fromMe) {
|
||
message.pushName = "Você";
|
||
} else if (message.contextInfo) {
|
||
const contextInfo = message.contextInfo as { participant?: string };
|
||
if (contextInfo.participant) {
|
||
message.pushName = contextInfo.participant.split("@")[0];
|
||
} else if (messageKey.participant) {
|
||
message.pushName = messageKey.participant.split("@")[0];
|
||
}
|
||
}
|
||
}
|
||
|
||
return message;
|
||
});
|
||
|
||
return {
|
||
messages: {
|
||
total: count,
|
||
pages: Math.ceil(count / query.offset),
|
||
currentPage: query.page,
|
||
records: formattedMessages,
|
||
},
|
||
};
|
||
}
|
||
}
|