diff --git a/package-lock.json b/package-lock.json index c45e8fef38..4f8d2d5202 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15080,7 +15080,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -15097,7 +15096,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -15114,7 +15112,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -15131,7 +15128,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -15148,7 +15144,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -15165,7 +15160,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -15182,7 +15176,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -15199,7 +15192,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -15216,7 +15208,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -15233,7 +15224,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -15250,7 +15240,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -15267,7 +15256,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -15284,7 +15272,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -15301,7 +15288,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -15318,7 +15304,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -15335,7 +15320,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -15352,7 +15336,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -15369,7 +15352,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -15386,7 +15368,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -15403,7 +15384,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -15420,7 +15400,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -15437,7 +15416,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -15454,7 +15432,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -15471,7 +15448,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -15488,7 +15464,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -15505,7 +15480,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ diff --git a/src/api/controllers/chat.controller.ts b/src/api/controllers/chat.controller.ts index 22e90b9faa..2e2554dea5 100644 --- a/src/api/controllers/chat.controller.ts +++ b/src/api/controllers/chat.controller.ts @@ -113,4 +113,20 @@ export class ChatController { public async blockUser({ instanceName }: InstanceDto, data: BlockUserDto) { return await this.waMonitor.waInstances[instanceName].blockUser(data); } + + public async fetchChannels({ instanceName }: InstanceDto, query: Query) { + return await this.waMonitor.waInstances[instanceName].fetchChannels(query); + } + + /** Forked patch (v2.3.7-lp): resolve invite code -> JID + metadata */ + public async newsletterMetadataFromInvite( + { instanceName }: InstanceDto, + data: { inviteCode?: string; code?: string; url?: string }, + ) { + const invite = data.inviteCode ?? data.code ?? data.url; + if (!invite) { + throw new Error('body must include one of: inviteCode | code | url'); + } + return await (this.waMonitor.waInstances[instanceName] as any).newsletterMetadataFromInvite(invite); + } } diff --git a/src/api/dto/sendMessage.dto.ts b/src/api/dto/sendMessage.dto.ts index ba9ecf527c..61193d73bc 100644 --- a/src/api/dto/sendMessage.dto.ts +++ b/src/api/dto/sendMessage.dto.ts @@ -5,11 +5,29 @@ export class Quoted { message: proto.IMessage; } +/** + * Forked patch (ligge/evolution-api v2.3.7-lp): permite injetar preview + * de link pre-montado, contornando a lib de link-preview do Baileys que + * falha com shorteners (amzn.to, bit.ly, etc). O dispatcher resolve o + * shortener + scrape de og:* e envia estes campos prontos. + */ +export class LinkPreviewOverride { + title?: string; + description?: string; + /** URL canonica que o Baileys vai usar no card (sem trocar a URL do texto) */ + canonicalUrl?: string; + /** URL da imagem de thumbnail; Baileys baixa e embute */ + thumbnailUrl?: string; + /** Alternativa: base64 puro (sem prefixo data:) da thumbnail em JPEG */ + jpegThumbnail?: string; +} + export class Options { delay?: number; presence?: WAPresence; quoted?: Quoted; linkPreview?: boolean; + linkPreviewOverride?: LinkPreviewOverride; encoding?: boolean; mentionsEveryOne?: boolean; mentioned?: string[]; @@ -41,6 +59,8 @@ export class Metadata { delay?: number; quoted?: Quoted; linkPreview?: boolean; + /** Patch fork: preview de link pre-montado (contorna Baileys com shorteners) */ + linkPreviewOverride?: LinkPreviewOverride; mentionsEveryOne?: boolean; mentioned?: string[]; encoding?: boolean; diff --git a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts index 60e857fcc1..499921f3e1 100644 --- a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts +++ b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts @@ -2338,7 +2338,14 @@ export class BaileysStartupService extends ChannelStartupService { } } - const linkPreview = options?.linkPreview != false ? undefined : false; + // Forked patch (v2.3.7-lp): override tem prioridade. Se o caller + // passou um objeto WAUrlInfo pronto, usa-o; senao, delega ao Baileys + // (undefined = auto-detect, false = desabilita). + const linkPreview: any = options?.linkPreviewOverride + ? options.linkPreviewOverride + : options?.linkPreview != false + ? undefined + : false; let quoted: WAMessage; @@ -2633,6 +2640,60 @@ export class BaileysStartupService extends ChannelStartupService { throw new BadRequestException('Text is required'); } + // Forked patch (v2.3.7-lp): monta WAUrlInfo se veio linkPreviewOverride. + // v2.3.7.1: faz upload real da imagem via prepareWAMessageMedia pra ter + // highQualityThumbnail completo (directPath/mediaKey/...), fazendo WhatsApp + // renderizar como HERO CARD (imagem grande) em vez de thumbnail lateral. + let linkPreviewOverride: any = undefined; + if (data.linkPreviewOverride) { + const ov = data.linkPreviewOverride; + const urlMatch = text.match(/https?:\/\/\S+/i); + if (urlMatch) { + const base: any = { + 'matched-text': urlMatch[0], + matchedText: urlMatch[0], + 'canonical-url': ov.canonicalUrl ?? urlMatch[0], + canonicalUrl: ov.canonicalUrl ?? urlMatch[0], + title: ov.title, + description: ov.description, + }; + + // Tenta upload pra ter hero card. Se falhar, fallback pra thumbnail simples. + const imgSource = ov.jpegThumbnail + ? Buffer.from(ov.jpegThumbnail, 'base64') + : ov.thumbnailUrl + ? { url: ov.thumbnailUrl } + : undefined; + + if (imgSource) { + try { + const { imageMessage } = await prepareWAMessageMedia({ image: imgSource as any }, { + upload: this.client.waUploadToServer, + mediaTypeOverride: 'thumbnail-link' as any, + } as any); + if (imageMessage) { + base.jpegThumbnail = imageMessage.jpegThumbnail + ? Buffer.from(imageMessage.jpegThumbnail as any) + : undefined; + base.highQualityThumbnail = imageMessage; + } + } catch (err) { + this.logger.warn( + `linkPreviewOverride: upload failed, falling back to simple thumbnail: ${err?.message ?? err}`, + ); + if (ov.jpegThumbnail) { + base.jpegThumbnail = Buffer.from(ov.jpegThumbnail, 'base64'); + } else if (ov.thumbnailUrl) { + base.thumbnailUrl = ov.thumbnailUrl; + base['thumbnail-url'] = ov.thumbnailUrl; + } + } + } + + linkPreviewOverride = base; + } + } + return await this.sendMessageWithTyping( data.number, { conversation: data.text }, @@ -2641,6 +2702,7 @@ export class BaileysStartupService extends ChannelStartupService { presence: 'composing', quoted: data?.quoted, linkPreview: data?.linkPreview, + linkPreviewOverride, mentionsEveryOne: data?.mentionsEveryOne, mentioned: data?.mentioned, }, @@ -3511,9 +3573,24 @@ export class BaileysStartupService extends ChannelStartupService { users: { number: string; jid: string; name?: string }[]; } = { groups: [], broadcast: [], users: [] }; + const onWhatsapp: OnWhatsAppDto[] = []; + data.numbers.forEach((number) => { const jid = createJid(number); + if (isJidNewsletter(jid)) { + onWhatsapp.push( + new OnWhatsAppDto( + jid, + true, // Newsletters are always valid + number, + undefined, // Can be fetched later if needed + 'newsletter', // Indicate it's a newsletter type + ), + ); + return; + } + if (isJidGroup(jid)) { jids.groups.push({ number, jid }); } else if (jid === 'status@broadcast') { @@ -3523,8 +3600,6 @@ export class BaileysStartupService extends ChannelStartupService { } }); - const onWhatsapp: OnWhatsAppDto[] = []; - // BROADCAST onWhatsapp.push(...jids.broadcast.map(({ jid, number }) => new OnWhatsAppDto(jid, false, number))); @@ -4700,6 +4775,10 @@ export class BaileysStartupService extends ChannelStartupService { } } + if (isJidNewsletter(message.key.remoteJid) && message.key.fromMe) { + messageRaw.status = status[3]; // DELIVERED MESSAGE TO NEWSLETTER CHANNEL + } + return messageRaw; } @@ -5119,4 +5198,77 @@ export class BaileysStartupService extends ChannelStartupService { }, }; } + /** + * Forked patch (v2.3.7-lp): resolve invite code de canal -> JID + metadata. + * Usa Baileys newsletterMetadata('invite', code) pra pegar JID real a partir + * do link https://whatsapp.com/channel/. Essencial pra permitir + * envio em canais sem o usuario precisar enviar 1 msg manual primeiro. + */ + public async newsletterMetadataFromInvite(inviteCode: string) { + if (!inviteCode || typeof inviteCode !== 'string') { + throw new BadRequestException('inviteCode required'); + } + const code = inviteCode.trim().replace(/^https?:\/\/whatsapp\.com\/channel\//i, ''); + try { + const metadata = await (this.client as any).newsletterMetadata('invite', code); + if (!metadata) { + throw new NotFoundException(`newsletter invite "${code}" not found`); + } + return metadata; + } catch (err) { + if (err instanceof BadRequestException || err instanceof NotFoundException) { + throw err; + } + this.logger.error(`newsletterMetadataFromInvite failed: ${err?.message}`); + throw new InternalServerErrorException(`newsletter metadata lookup failed: ${err?.message ?? err}`); + } + } + + public async fetchChannels(query: Query) { + const page = Number((query as any)?.page ?? 1); + const limit = Number((query as any)?.limit ?? (query as any)?.rows ?? 50); + const skip = (page - 1) * limit; + + const messages = await this.prismaRepository.message.findMany({ + where: { + instanceId: this.instanceId, + AND: [{ key: { path: ['remoteJid'], not: null } }], + }, + orderBy: { messageTimestamp: 'desc' }, + select: { + key: true, + messageTimestamp: true, + }, + }); + + const channelMap = new Map(); + + for (const msg of messages) { + const key = msg.key as any; + const remoteJid = key?.remoteJid as string | undefined; + if (!remoteJid || !isJidNewsletter(remoteJid)) continue; + + if (!channelMap.has(remoteJid)) { + channelMap.set(remoteJid, { + remoteJid, + pushName: undefined, // Push name is never stored for channels, so we set it as undefined + lastMessageTimestamp: msg.messageTimestamp, + }); + } + } + + const allChannels = Array.from(channelMap.values()); + + const total = allChannels.length; + const pages = Math.ceil(total / limit); + const records = allChannels.slice(skip, skip + limit); + + return { + total, + pages, + currentPage: page, + limit, + records, + }; + } } diff --git a/src/api/routes/chat.router.ts b/src/api/routes/chat.router.ts index 158947ed22..438de24d22 100644 --- a/src/api/routes/chat.router.ts +++ b/src/api/routes/chat.router.ts @@ -281,6 +281,23 @@ export class ChatRouter extends RouterBroker { }); return res.status(HttpStatus.CREATED).json(response); + }) + .post(this.routerPath('findChannels'), ...guards, async (req, res) => { + const response = await this.dataValidate({ + request: req, + schema: contactValidateSchema, + ClassRef: Query, + execute: (instance, query) => chatController.fetchChannels(instance, query as any), + }); + + return res.status(HttpStatus.OK).json(response); + }) + // Forked patch (v2.3.7-lp): resolve invite code -> JID do canal + .post(this.routerPath('newsletterMetadataFromInvite'), ...guards, async (req, res) => { + const instance = req.params as any; + const body = req.body ?? {}; + const response = await chatController.newsletterMetadataFromInvite(instance, body); + return res.status(HttpStatus.OK).json(response); }); } diff --git a/src/utils/createJid.ts b/src/utils/createJid.ts index a680e821e1..23a3afe1fa 100644 --- a/src/utils/createJid.ts +++ b/src/utils/createJid.ts @@ -35,7 +35,12 @@ function formatBRNumber(jid: string) { export function createJid(number: string): string { number = number.replace(/:\d+/, ''); - if (number.includes('@g.us') || number.includes('@s.whatsapp.net') || number.includes('@lid')) { + if ( + number.includes('@g.us') || + number.includes('@s.whatsapp.net') || + number.includes('@lid') || + number.includes('@newsletter') + ) { return number; } diff --git a/src/validate/message.schema.ts b/src/validate/message.schema.ts index d514c6199e..d62333674f 100644 --- a/src/validate/message.schema.ts +++ b/src/validate/message.schema.ts @@ -72,6 +72,17 @@ export const textMessageSchema: JSONSchema7 = { number: { ...numberDefinition }, text: { type: 'string' }, linkPreview: { type: 'boolean' }, + // Forked patch (v2.3.7-lp): preview de link pre-montado pra shorteners + linkPreviewOverride: { + type: 'object', + properties: { + title: { type: 'string' }, + description: { type: 'string' }, + canonicalUrl: { type: 'string' }, + thumbnailUrl: { type: 'string' }, + jpegThumbnail: { type: 'string' }, + }, + }, delay: { type: 'integer', description: 'Enter a value in milliseconds',