diff --git a/src/Socket/groups.ts b/src/Socket/groups.ts index c001779..e979a51 100644 --- a/src/Socket/groups.ts +++ b/src/Socket/groups.ts @@ -359,3 +359,5 @@ export const extractGroupMetadata = (result: BinaryNode) => { } return metadata } + +export type GroupsSocket = ReturnType diff --git a/src/Socket/messages-recv.ts b/src/Socket/messages-recv.ts index e13bc7c..4115bde 100644 --- a/src/Socket/messages-recv.ts +++ b/src/Socket/messages-recv.ts @@ -47,6 +47,7 @@ import { getBinaryNodeChild, getBinaryNodeChildBuffer, getBinaryNodeChildren, + getBinaryNodeChildString, isJidGroup, isJidStatusBroadcast, isJidUser, @@ -403,6 +404,12 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { logger.debug({ jid }, 'got privacy token update') } + break + case 'newsletter': + await handleNewsletterNotification(node) + break + case 'mex': + await handleMexNewsletterNotification(node) break case 'w:gp2': handleGroupNotification(node.attrs.participant, child, result) @@ -1083,6 +1090,158 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { } } + // Handles newsletter notifications + async function handleNewsletterNotification(node: BinaryNode) { + const from = node.attrs.from + const [child] = getAllBinaryNodeChildren(node) + const author = node.attrs.participant + + logger.info({ from, child }, 'got newsletter notification') + + switch (child.tag) { + case 'reaction': + const reactionUpdate = { + id: from, + server_id: child.attrs.message_id, + reaction: { + code: getBinaryNodeChildString(child, 'reaction'), + count: 1 + } + } + ev.emit('newsletter.reaction', reactionUpdate) + break + + case 'view': + const viewUpdate = { + id: from, + server_id: child.attrs.message_id, + count: parseInt(child.content?.toString() || '0', 10) + } + ev.emit('newsletter.view', viewUpdate) + break + + case 'participant': + const participantUpdate = { + id: from, + author, + user: child.attrs.jid, + action: child.attrs.action, + new_role: child.attrs.role + } + ev.emit('newsletter-participants.update', participantUpdate) + break + + case 'update': + const settingsNode = getBinaryNodeChild(child, 'settings') + if (settingsNode) { + const update: Record = {} + const nameNode = getBinaryNodeChild(settingsNode, 'name') + if (nameNode?.content) update.name = nameNode.content.toString() + + const descriptionNode = getBinaryNodeChild(settingsNode, 'description') + if (descriptionNode?.content) update.description = descriptionNode.content.toString() + + ev.emit('newsletter-settings.update', { + id: from, + update + }) + } + + break + + case 'message': + const plaintextNode = getBinaryNodeChild(child, 'plaintext') + if (plaintextNode?.content) { + try { + const contentBuf = + typeof plaintextNode.content === 'string' + ? Buffer.from(plaintextNode.content, 'binary') + : Buffer.from(plaintextNode.content as Uint8Array) + const messageProto = proto.Message.decode(contentBuf) + const fullMessage = proto.WebMessageInfo.fromObject({ + key: { + remoteJid: from, + id: child.attrs.message_id || child.attrs.server_id, + fromMe: false + }, + message: messageProto, + messageTimestamp: +child.attrs.t + }) + await upsertMessage(fullMessage, 'append') + logger.info('Processed plaintext newsletter message') + } catch (error) { + logger.error({ error }, 'Failed to decode plaintext newsletter message') + } + } + + break + + default: + logger.warn({ node }, 'Unknown newsletter notification') + break + } + } + + // Handles mex newsletter notifications + async function handleMexNewsletterNotification(node: BinaryNode) { + const mexNode = getBinaryNodeChild(node, 'mex') + if (!mexNode?.content) { + logger.warn({ node }, 'Invalid mex newsletter notification') + return + } + + let data: any + try { + data = JSON.parse(mexNode.content.toString()) + } catch (error) { + logger.error({ err: error, node }, 'Failed to parse mex newsletter notification') + return + } + + const operation = data?.operation + const updates = data?.updates + + if (!updates || !operation) { + logger.warn({ data }, 'Invalid mex newsletter notification content') + return + } + + logger.info({ operation, updates }, 'got mex newsletter notification') + + switch (operation) { + case 'NotificationNewsletterUpdate': + for (const update of updates) { + if (update.jid && update.settings && Object.keys(update.settings).length > 0) { + ev.emit('newsletter-settings.update', { + id: update.jid, + update: update.settings + }) + } + } + + break + + case 'NotificationNewsletterAdminPromote': + for (const update of updates) { + if (update.jid && update.user) { + ev.emit('newsletter-participants.update', { + id: update.jid, + author: node.attrs.from, + user: update.user, + new_role: 'ADMIN', + action: 'promote' + }) + } + } + + break + + default: + logger.info({ operation, data }, 'Unhandled mex newsletter notification') + break + } + } + // recv a message ws.on('CB:message', (node: BinaryNode) => { processNode('message', node, 'processing message', handleMessage) diff --git a/src/Socket/messages-send.ts b/src/Socket/messages-send.ts index 85fbfc2..f266955 100644 --- a/src/Socket/messages-send.ts +++ b/src/Socket/messages-send.ts @@ -16,6 +16,7 @@ import { assertMediaContent, bindWaitForEvent, decryptMediaRetryData, + encodeNewsletterMessage, encodeSignedDeviceIdentity, encodeWAMessage, encryptMediaRetryRequest, @@ -46,6 +47,7 @@ import { } from '../WABinary' import { USyncQuery, USyncUser } from '../WAUSync' import { makeGroupsSocket } from './groups' +import { makeNewsletterSocket, NewsletterSocket } from './newsletter' export const makeMessagesSocket = (config: SocketConfig) => { const { @@ -56,7 +58,7 @@ export const makeMessagesSocket = (config: SocketConfig) => { patchMessageBeforeSending, cachedGroupMetadata } = config - const sock = makeGroupsSocket(config) + const sock: NewsletterSocket = makeNewsletterSocket(makeGroupsSocket(config)) const { ev, authState, @@ -373,6 +375,7 @@ export const makeMessagesSocket = (config: SocketConfig) => { const isGroup = server === 'g.us' const isStatus = jid === statusJid const isLid = server === 'lid' + const isNewsletter = server === 'newsletter' msgId = msgId || generateMessageIDV2(sock.user?.id) useUserDevicesCache = useUserDevicesCache !== false @@ -411,6 +414,30 @@ export const makeMessagesSocket = (config: SocketConfig) => { extraAttrs['mediatype'] = mediaType } + if (isNewsletter) { + // Patch message if needed, then encode as plaintext + const patched = patchMessageBeforeSending ? await patchMessageBeforeSending(message, []) : message + const bytes = encodeNewsletterMessage(patched as proto.IMessage) + binaryNodeContent.push({ + tag: 'plaintext', + attrs: {}, + content: bytes + }) + const stanza: BinaryNode = { + tag: 'message', + attrs: { + to: jid, + id: msgId, + type: getMessageType(message), + ...(additionalAttributes || {}) + }, + content: binaryNodeContent + } + logger.debug({ msgId }, `sending newsletter message to ${jid}`) + await sendNode(stanza) + return + } + if (normalizeMessageContent(message)?.pinInChatMessage) { extraAttrs['decrypt-fail'] = 'hide' } diff --git a/src/Socket/mex.ts b/src/Socket/mex.ts new file mode 100644 index 0000000..ae976f1 --- /dev/null +++ b/src/Socket/mex.ts @@ -0,0 +1,58 @@ +import { Boom } from '@hapi/boom' +import { BinaryNode } from '../WABinary' +import { getBinaryNodeChild, S_WHATSAPP_NET } from '../WABinary' + +const wMexQuery = ( + variables: Record, + queryId: string, + query: (node: BinaryNode) => Promise, + generateMessageTag: () => string +) => { + return query({ + tag: 'iq', + attrs: { + id: generateMessageTag(), + type: 'get', + to: S_WHATSAPP_NET, + xmlns: 'w:mex' + }, + content: [ + { + tag: 'query', + attrs: { query_id: queryId }, + content: Buffer.from(JSON.stringify({ variables }), 'utf-8') + } + ] + }) +} + +export const executeWMexQuery = async ( + variables: Record, + queryId: string, + dataPath: string, + query: (node: BinaryNode) => Promise, + generateMessageTag: () => string +): Promise => { + const result = await wMexQuery(variables, queryId, query, generateMessageTag) + const child = getBinaryNodeChild(result, 'result') + if (child?.content) { + const data = JSON.parse(child.content.toString()) + + if (data.errors && data.errors.length > 0) { + const errorMessages = data.errors.map((err: Error) => err.message || 'Unknown error').join(', ') + const firstError = data.errors[0] + const errorCode = firstError.extensions?.error_code || 400 + throw new Boom(`GraphQL server error: ${errorMessages}`, { statusCode: errorCode, data: firstError }) + } + + const response = dataPath ? data?.data?.[dataPath] : data?.data + if (typeof response !== 'undefined') { + return response as T + } + } + + const action = (dataPath || '').startsWith('xwa2_') + ? dataPath.substring(5).replace(/_/g, ' ') + : dataPath?.replace(/_/g, ' ') + throw new Boom(`Failed to ${action}, unexpected response structure.`, { statusCode: 400, data: result }) +} diff --git a/src/Socket/newsletter.ts b/src/Socket/newsletter.ts new file mode 100644 index 0000000..c0457e8 --- /dev/null +++ b/src/Socket/newsletter.ts @@ -0,0 +1,227 @@ +import type { NewsletterCreateResponse, WAMediaUpload } from '../Types' +import { NewsletterMetadata, NewsletterUpdate, QueryIds, XWAPaths } from '../Types' +import { generateProfilePicture } from '../Utils/messages-media' +import { getBinaryNodeChild } from '../WABinary' +import { GroupsSocket } from './groups' +import { executeWMexQuery as genericExecuteWMexQuery } from './mex' + +const parseNewsletterCreateResponse = (response: NewsletterCreateResponse): NewsletterMetadata => { + const { id, thread_metadata: thread, viewer_metadata: viewer } = response + return { + id: id, + owner: undefined, + name: thread.name.text, + creation_time: parseInt(thread.creation_time, 10), + description: thread.description.text, + invite: thread.invite, + subscribers: parseInt(thread.subscribers_count, 10), + verification: thread.verification, + picture: { + id: thread.picture.id, + directPath: thread.picture.direct_path + }, + mute_state: viewer.mute + } +} + +const parseNewsletterMetadata = (result: unknown): NewsletterMetadata | null => { + if (typeof result !== 'object' || result === null) { + return null + } + + if ('id' in result && typeof result.id === 'string') { + return result as NewsletterMetadata + } + + if ('result' in result && typeof result.result === 'object' && result.result !== null && 'id' in result.result) { + return result.result as NewsletterMetadata + } + + return null +} + +export const makeNewsletterSocket = (sock: GroupsSocket) => { + const { query, generateMessageTag } = sock + + const executeWMexQuery = (variables: Record, queryId: string, dataPath: string): Promise => { + return genericExecuteWMexQuery(variables, queryId, dataPath, query, generateMessageTag) + } + + const newsletterUpdate = async (jid: string, updates: NewsletterUpdate) => { + const variables = { + newsletter_id: jid, + updates: { + ...updates, + settings: null + } + } + return executeWMexQuery(variables, QueryIds.UPDATE_METADATA, 'xwa2_newsletter_update') + } + + return { + ...sock, + newsletterCreate: async (name: string, description?: string): Promise => { + const variables = { + input: { + name, + description: description ?? null + } + } + const rawResponse = await executeWMexQuery( + variables, + QueryIds.CREATE, + XWAPaths.xwa2_newsletter_create + ) + return parseNewsletterCreateResponse(rawResponse) + }, + + newsletterUpdate, + + newsletterSubscribers: async (jid: string) => { + return executeWMexQuery<{ subscribers: number }>( + { newsletter_id: jid }, + QueryIds.SUBSCRIBERS, + XWAPaths.xwa2_newsletter_subscribers + ) + }, + + newsletterMetadata: async (type: 'invite' | 'jid', key: string) => { + const variables = { + fetch_creation_time: true, + fetch_full_image: true, + fetch_viewer_metadata: true, + input: { + key, + type: type.toUpperCase() + } + } + const result = await executeWMexQuery(variables, QueryIds.METADATA, XWAPaths.xwa2_newsletter_metadata) + return parseNewsletterMetadata(result) + }, + + newsletterFollow: (jid: string) => { + return executeWMexQuery({ newsletter_id: jid }, QueryIds.FOLLOW, XWAPaths.xwa2_newsletter_follow) + }, + + newsletterUnfollow: (jid: string) => { + return executeWMexQuery({ newsletter_id: jid }, QueryIds.UNFOLLOW, XWAPaths.xwa2_newsletter_unfollow) + }, + + newsletterMute: (jid: string) => { + return executeWMexQuery({ newsletter_id: jid }, QueryIds.MUTE, XWAPaths.xwa2_newsletter_mute_v2) + }, + + newsletterUnmute: (jid: string) => { + return executeWMexQuery({ newsletter_id: jid }, QueryIds.UNMUTE, XWAPaths.xwa2_newsletter_unmute_v2) + }, + + newsletterUpdateName: async (jid: string, name: string) => { + return await newsletterUpdate(jid, { name }) + }, + + newsletterUpdateDescription: async (jid: string, description: string) => { + return await newsletterUpdate(jid, { description }) + }, + + newsletterUpdatePicture: async (jid: string, content: WAMediaUpload) => { + const { img } = await generateProfilePicture(content) + return await newsletterUpdate(jid, { picture: img.toString('base64') }) + }, + + newsletterRemovePicture: async (jid: string) => { + return await newsletterUpdate(jid, { picture: '' }) + }, + + newsletterReactMessage: async (jid: string, serverId: string, reaction?: string) => { + await query({ + tag: 'message', + attrs: { + to: jid, + ...(reaction ? {} : { edit: '7' }), + type: 'reaction', + server_id: serverId, + id: generateMessageTag() + }, + content: [ + { + tag: 'reaction', + attrs: reaction ? { code: reaction } : {} + } + ] + }) + }, + + newsletterFetchMessages: async (jid: string, count: number, since: number, after: number) => { + const messageUpdateAttrs: { count: string; since?: string; after?: string } = { + count: count.toString() + } + if (typeof since === 'number') { + messageUpdateAttrs.since = since.toString() + } + + if (after) { + messageUpdateAttrs.after = after.toString() + } + + const result = await query({ + tag: 'iq', + attrs: { + id: generateMessageTag(), + type: 'get', + xmlns: 'newsletter', + to: jid + }, + content: [ + { + tag: 'message_updates', + attrs: messageUpdateAttrs + } + ] + }) + return result + }, + + subscribeNewsletterUpdates: async (jid: string): Promise<{ duration: string } | null> => { + const result = await query({ + tag: 'iq', + attrs: { + id: generateMessageTag(), + type: 'set', + xmlns: 'newsletter', + to: jid + }, + content: [{ tag: 'live_updates', attrs: {}, content: [] }] + }) + const liveUpdatesNode = getBinaryNodeChild(result, 'live_updates') + const duration = liveUpdatesNode?.attrs?.duration + return duration ? { duration: duration } : null + }, + + newsletterAdminCount: async (jid: string): Promise => { + const response = await executeWMexQuery<{ admin_count: number }>( + { newsletter_id: jid }, + QueryIds.ADMIN_COUNT, + XWAPaths.xwa2_newsletter_admin_count + ) + return response.admin_count + }, + + newsletterChangeOwner: async (jid: string, newOwnerJid: string) => { + await executeWMexQuery( + { newsletter_id: jid, user_id: newOwnerJid }, + QueryIds.CHANGE_OWNER, + XWAPaths.xwa2_newsletter_change_owner + ) + }, + + newsletterDemote: async (jid: string, userJid: string) => { + await executeWMexQuery({ newsletter_id: jid, user_id: userJid }, QueryIds.DEMOTE, XWAPaths.xwa2_newsletter_demote) + }, + + newsletterDelete: async (jid: string) => { + await executeWMexQuery({ newsletter_id: jid }, QueryIds.DELETE, XWAPaths.xwa2_newsletter_delete_v2) + } + } +} + +export type NewsletterSocket = ReturnType diff --git a/src/Types/Events.ts b/src/Types/Events.ts index 813e5d0..f7d7ff4 100644 --- a/src/Types/Events.ts +++ b/src/Types/Events.ts @@ -71,6 +71,16 @@ export type BaileysEventMap = { call: WACallEvent[] 'labels.edit': Label 'labels.association': { association: LabelAssociation; type: 'add' | 'remove' } + + /** Newsletter-related events */ + 'newsletter.reaction': { + id: string + server_id: string + reaction: { code?: string; count?: number; removed?: boolean } + } + 'newsletter.view': { id: string; server_id: string; count: number } + 'newsletter-participants.update': { id: string; author: string; user: string; new_role: string; action: string } + 'newsletter-settings.update': { id: string; update: any } } export type BufferedEventData = { diff --git a/src/Types/Message.ts b/src/Types/Message.ts index 5c91419..2ef4b34 100644 --- a/src/Types/Message.ts +++ b/src/Types/Message.ts @@ -15,6 +15,7 @@ export type WAContactMessage = proto.Message.IContactMessage export type WAContactsArrayMessage = proto.Message.IContactsArrayMessage export type WAMessageKey = proto.IMessageKey & { senderLid?: string + server_id?: string senderPn?: string participantLid?: string participantPn?: string @@ -292,6 +293,7 @@ export type MediaGenerationOptions = { export type MessageContentGenerationOptions = MediaGenerationOptions & { getUrlInfo?: (text: string) => Promise getProfilePicUrl?: (jid: string, type: 'image' | 'preview') => Promise + jid?: string } export type MessageGenerationOptions = MessageContentGenerationOptions & MessageGenerationOptionsFromContent diff --git a/src/Types/Newsletter.ts b/src/Types/Newsletter.ts new file mode 100644 index 0000000..4355eca --- /dev/null +++ b/src/Types/Newsletter.ts @@ -0,0 +1,98 @@ +export enum XWAPaths { + xwa2_newsletter_create = 'xwa2_newsletter_create', + xwa2_newsletter_subscribers = 'xwa2_newsletter_subscribers', + xwa2_newsletter_view = 'xwa2_newsletter_view', + xwa2_newsletter_metadata = 'xwa2_newsletter', + xwa2_newsletter_admin_count = 'xwa2_newsletter_admin', + xwa2_newsletter_mute_v2 = 'xwa2_newsletter_mute_v2', + xwa2_newsletter_unmute_v2 = 'xwa2_newsletter_unmute_v2', + xwa2_newsletter_follow = 'xwa2_newsletter_follow', + xwa2_newsletter_unfollow = 'xwa2_newsletter_unfollow', + xwa2_newsletter_change_owner = 'xwa2_newsletter_change_owner', + xwa2_newsletter_demote = 'xwa2_newsletter_demote', + xwa2_newsletter_delete_v2 = 'xwa2_newsletter_delete_v2' +} +export enum QueryIds { + CREATE = '8823471724422422', + UPDATE_METADATA = '24250201037901610', + METADATA = '6563316087068696', + SUBSCRIBERS = '9783111038412085', + FOLLOW = '7871414976211147', + UNFOLLOW = '7238632346214362', + MUTE = '29766401636284406', + UNMUTE = '9864994326891137', + ADMIN_COUNT = '7130823597031706', + CHANGE_OWNER = '7341777602580933', + DEMOTE = '6551828931592903', + DELETE = '30062808666639665' +} +export type NewsletterUpdate = { + name?: string + description?: string + picture?: string +} +export interface NewsletterCreateResponse { + id: string + state: { type: string } + thread_metadata: { + creation_time: string + description: { id: string; text: string; update_time: string } + handle: string | null + invite: string + name: { id: string; text: string; update_time: string } + picture: { direct_path: string; id: string; type: string } + preview: { direct_path: string; id: string; type: string } + subscribers_count: string + verification: 'VERIFIED' | 'UNVERIFIED' + } + viewer_metadata: { + mute: 'ON' | 'OFF' + role: NewsletterViewRole + } +} +export interface NewsletterCreateResponse { + id: string + state: { type: string } + thread_metadata: { + creation_time: string + description: { id: string; text: string; update_time: string } + handle: string | null + invite: string + name: { id: string; text: string; update_time: string } + picture: { direct_path: string; id: string; type: string } + preview: { direct_path: string; id: string; type: string } + subscribers_count: string + verification: 'VERIFIED' | 'UNVERIFIED' + } + viewer_metadata: { + mute: 'ON' | 'OFF' + role: NewsletterViewRole + } +} +export type NewsletterViewRole = 'ADMIN' | 'GUEST' | 'OWNER' | 'SUBSCRIBER' +export interface NewsletterMetadata { + id: string + owner?: string + name: string + description?: string + invite?: string + creation_time?: number + subscribers?: number + picture?: { + url?: string + directPath?: string + mediaKey?: string + id?: string + } + verification?: 'VERIFIED' | 'UNVERIFIED' + reaction_codes?: { + code: string + count: number + }[] + mute_state?: 'ON' | 'OFF' + thread_metadata?: { + creation_time?: number + name?: string + description?: string + } +} diff --git a/src/Types/index.ts b/src/Types/index.ts index 1a22e82..079658d 100644 --- a/src/Types/index.ts +++ b/src/Types/index.ts @@ -9,6 +9,7 @@ export * from './Events' export * from './Product' export * from './Call' export * from './Signal' +export * from './Newsletter' import { AuthenticationState } from './Auth' import { SocketConfig } from './Socket' diff --git a/src/Utils/decode-wa-message.ts b/src/Utils/decode-wa-message.ts index 37e6f4a..3a470f7 100644 --- a/src/Utils/decode-wa-message.ts +++ b/src/Utils/decode-wa-message.ts @@ -114,7 +114,8 @@ export function decodeMessageNode(stanza: BinaryNode, meId: string, meLid: strin senderPn: stanza?.attrs?.sender_pn, participant, participantPn: stanza?.attrs?.participant_pn, - participantLid: stanza?.attrs?.participant_lid + participantLid: stanza?.attrs?.participant_lid, + ...(msgType === 'newsletter' && stanza.attrs.server_id ? { server_id: stanza.attrs.server_id } : {}) } const fullMessage: proto.IWebMessageInfo = { diff --git a/src/Utils/generics.ts b/src/Utils/generics.ts index 37d3bf7..c2b5192 100644 --- a/src/Utils/generics.ts +++ b/src/Utils/generics.ts @@ -443,3 +443,7 @@ export function bytesToCrockford(buffer: Buffer): string { return crockford.join('') } + +export function encodeNewsletterMessage(message: proto.IMessage): Uint8Array { + return proto.Message.encode(message).finish() +} diff --git a/src/Utils/messages-media.ts b/src/Utils/messages-media.ts index 6bbf072..61d94f8 100644 --- a/src/Utils/messages-media.ts +++ b/src/Utils/messages-media.ts @@ -60,6 +60,47 @@ export const hkdfInfoKey = (type: MediaType) => { return `WhatsApp ${hkdfInfo} Keys` } +export const getRawMediaUploadData = async (media: WAMediaUpload, mediaType: MediaType, logger?: ILogger) => { + const { stream } = await getStream(media) + logger?.debug('got stream for raw upload') + + const hasher = Crypto.createHash('sha256') + const filePath = join(tmpdir(), mediaType + generateMessageIDV2()) + const fileWriteStream = createWriteStream(filePath) + + let fileLength = 0 + try { + for await (const data of stream) { + fileLength += data.length + hasher.update(data) + if (!fileWriteStream.write(data)) { + await once(fileWriteStream, 'drain') + } + } + + fileWriteStream.end() + await once(fileWriteStream, 'finish') + stream.destroy() + const fileSha256 = hasher.digest() + logger?.debug('hashed data for raw upload') + return { + filePath: filePath, + fileSha256, + fileLength + } + } catch (error) { + fileWriteStream.destroy() + stream.destroy() + try { + await fs.unlink(filePath) + } catch { + // + } + + throw error + } +} + /** generates all the keys required to encrypt/decrypt & sign a media message */ export async function getMediaKeys( buffer: Uint8Array | string | null | undefined, @@ -143,22 +184,24 @@ export const generateProfilePicture = async ( mediaUpload: WAMediaUpload, dimensions?: { width: number; height: number } ) => { + let buffer: Buffer + const { width: w = 640, height: h = 640 } = dimensions || {} - let bufferOrFilePath: Buffer | string if (Buffer.isBuffer(mediaUpload)) { - bufferOrFilePath = mediaUpload - } else if ('url' in mediaUpload) { - bufferOrFilePath = mediaUpload.url.toString() + buffer = mediaUpload } else { - bufferOrFilePath = await toBuffer(mediaUpload.stream) + // Use getStream to handle all WAMediaUpload types (Buffer, Stream, URL) + const { stream } = await getStream(mediaUpload) + // Convert the resulting stream to a buffer + buffer = await toBuffer(stream) } const lib = await getImageProcessingLibrary() let img: Promise if ('sharp' in lib && typeof lib.sharp?.default === 'function') { img = lib.sharp - .default(bufferOrFilePath) + .default(buffer) .resize(w, h) .jpeg({ quality: 50 @@ -166,7 +209,7 @@ export const generateProfilePicture = async ( .toBuffer() } else if ('jimp' in lib && typeof lib.jimp?.read === 'function') { const { read, MIME_JPEG, RESIZE_BILINEAR } = lib.jimp - const jimp = await read(bufferOrFilePath as string) + const jimp = await read(buffer) const min = Math.min(jimp.getWidth(), jimp.getHeight()) const cropped = jimp.crop(0, 0, min, min) diff --git a/src/Utils/messages.ts b/src/Utils/messages.ts index 1c618bf..8c69c2d 100644 --- a/src/Utils/messages.ts +++ b/src/Utils/messages.ts @@ -9,7 +9,6 @@ import { AnyMediaMessageContent, AnyMessageContent, DownloadableMessage, - MediaGenerationOptions, MediaType, MessageContentGenerationOptions, MessageGenerationOptions, @@ -23,7 +22,7 @@ import { WAProto, WATextMessage } from '../Types' -import { isJidGroup, isJidStatusBroadcast, jidNormalizedUser } from '../WABinary' +import { isJidGroup, isJidNewsletter, isJidStatusBroadcast, jidNormalizedUser } from '../WABinary' import { sha256 } from './crypto' import { generateMessageIDV2, getKeyAuthor, unixTimestampSeconds } from './generics' import { ILogger } from './logger' @@ -33,6 +32,7 @@ import { generateThumbnail, getAudioDuration, getAudioWaveform, + getRawMediaUploadData, MediaDownloadOptions } from './messages-media' @@ -108,7 +108,10 @@ const assertColor = async color => { } } -export const prepareWAMessageMedia = async (message: AnyMediaMessageContent, options: MediaGenerationOptions) => { +export const prepareWAMessageMedia = async ( + message: AnyMediaMessageContent, + options: MessageContentGenerationOptions +) => { const logger = options.logger let mediaType: (typeof MEDIA_KEYS)[number] | undefined @@ -127,13 +130,12 @@ export const prepareWAMessageMedia = async (message: AnyMediaMessageContent, opt media: message[mediaType] } delete uploadData[mediaType] - // check if cacheable + generate cache key + const cacheableKey = typeof uploadData.media === 'object' && 'url' in uploadData.media && !!uploadData.media.url && !!options.mediaCache && - // generate the key mediaType + ':' + uploadData.media.url.toString() if (mediaType === 'document' && !uploadData.fileName) { @@ -144,7 +146,6 @@ export const prepareWAMessageMedia = async (message: AnyMediaMessageContent, opt uploadData.mimetype = MIMETYPE_MAP[mediaType] } - // check for cache hit if (cacheableKey) { const mediaBuff = options.mediaCache!.get(cacheableKey) if (mediaBuff) { @@ -159,6 +160,48 @@ export const prepareWAMessageMedia = async (message: AnyMediaMessageContent, opt } } + const isNewsletter = !!options.jid && isJidNewsletter(options.jid) + if (isNewsletter) { + logger?.info({ key: cacheableKey }, 'Preparing raw media for newsletter') + const { filePath, fileSha256, fileLength } = await getRawMediaUploadData( + uploadData.media, + options.mediaTypeOverride || mediaType, + logger + ) + + const fileSha256B64 = fileSha256.toString('base64') + const { mediaUrl, directPath } = await options.upload(filePath, { + fileEncSha256B64: fileSha256B64, + mediaType: mediaType, + timeoutMs: options.mediaUploadTimeoutMs + }) + + await fs.unlink(filePath) + + const obj = WAProto.Message.fromObject({ + [`${mediaType}Message`]: MessageTypeProto[mediaType].fromObject({ + url: mediaUrl, + directPath, + fileSha256, + fileLength, + ...uploadData, + media: undefined + }) + }) + + if (uploadData.ptv) { + obj.ptvMessage = obj.videoMessage + delete obj.videoMessage + } + + if (cacheableKey) { + logger?.debug({ cacheableKey }, 'set cache') + options.mediaCache!.set(cacheableKey, WAProto.Message.encode(obj).finish()) + } + + return obj + } + const requiresDurationComputation = mediaType === 'audio' && typeof uploadData.seconds === 'undefined' const requiresThumbnailComputation = (mediaType === 'image' || mediaType === 'video') && typeof uploadData['jpegThumbnail'] === 'undefined' @@ -174,7 +217,7 @@ export const prepareWAMessageMedia = async (message: AnyMediaMessageContent, opt opts: options.options } ) - // url safe Base64 encode the SHA256 hash of the body + const fileEncSha256B64 = fileEncSha256.toString('base64') const [{ mediaUrl, directPath }] = await Promise.all([ (async () => { @@ -539,7 +582,7 @@ export const generateWAMessageFromContent = ( const timestamp = unixTimestampSeconds(options.timestamp) const { quoted, userJid } = options - if (quoted) { + if (quoted && !isJidNewsletter(jid)) { const participant = quoted.key.fromMe ? userJid : quoted.participant || quoted.key.participant || quoted.key.remoteJid @@ -574,7 +617,9 @@ export const generateWAMessageFromContent = ( // and it's not a protocol message -- delete, toggle disappear message key !== 'protocolMessage' && // already not converted to disappearing message - key !== 'ephemeralMessage' + key !== 'ephemeralMessage' && + // newsletters don't support ephemeral messages + !isJidNewsletter(jid) ) { innerMessage[key].contextInfo = { ...(innerMessage[key].contextInfo || {}), @@ -603,7 +648,8 @@ export const generateWAMessageFromContent = ( export const generateWAMessage = async (jid: string, content: AnyMessageContent, options: MessageGenerationOptions) => { // ensure msg ID is with every log options.logger = options?.logger?.child({ msgId: options.messageId }) - return generateWAMessageFromContent(jid, await generateWAMessageContent(content, options), options) + // Pass jid in the options to generateWAMessageContent + return generateWAMessageFromContent(jid, await generateWAMessageContent(content, { ...options, jid }), options) } /** Get the key to access the true type of content */