[READY FOR MERGE] Implement newsletter (#1532)

* feat: implement basic newsletter functionality with socket integration and event handling

* feat: enhance media handling for newsletters with raw media upload support

* feat: working updatePicture, removePicure, adminCount, mute, Unmute

* fix: fetchMessages

* chore: cleanup

* fix: update newsletter metadata path and query ID for consistency. newsletterMetadata works now

* chore: enhance newsletter metadata parsing and error handling

* fix: correct DELETE QueryId value in Newsletter.ts

* chore: split mex stuffs to own file

* chore: remove as any
This commit is contained in:
João Lucas de Oliveira Lopes
2025-06-30 23:22:09 -03:00
committed by GitHub
parent 5ffb19120d
commit 8391c02e0b
13 changed files with 697 additions and 19 deletions

View File

@@ -359,3 +359,5 @@ export const extractGroupMetadata = (result: BinaryNode) => {
}
return metadata
}
export type GroupsSocket = ReturnType<typeof makeGroupsSocket>

View File

@@ -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<string, any> = {}
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)

View File

@@ -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'
}

58
src/Socket/mex.ts Normal file
View File

@@ -0,0 +1,58 @@
import { Boom } from '@hapi/boom'
import { BinaryNode } from '../WABinary'
import { getBinaryNodeChild, S_WHATSAPP_NET } from '../WABinary'
const wMexQuery = (
variables: Record<string, unknown>,
queryId: string,
query: (node: BinaryNode) => Promise<BinaryNode>,
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 <T>(
variables: Record<string, unknown>,
queryId: string,
dataPath: string,
query: (node: BinaryNode) => Promise<BinaryNode>,
generateMessageTag: () => string
): Promise<T> => {
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 })
}

227
src/Socket/newsletter.ts Normal file
View File

@@ -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 = <T>(variables: Record<string, unknown>, queryId: string, dataPath: string): Promise<T> => {
return genericExecuteWMexQuery<T>(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<NewsletterMetadata> => {
const variables = {
input: {
name,
description: description ?? null
}
}
const rawResponse = await executeWMexQuery<NewsletterCreateResponse>(
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<unknown>(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<number> => {
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<typeof makeNewsletterSocket>

View File

@@ -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 = {

View File

@@ -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<WAUrlInfo | undefined>
getProfilePicUrl?: (jid: string, type: 'image' | 'preview') => Promise<string | undefined>
jid?: string
}
export type MessageGenerationOptions = MessageContentGenerationOptions & MessageGenerationOptionsFromContent

98
src/Types/Newsletter.ts Normal file
View File

@@ -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
}
}

View File

@@ -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'

View File

@@ -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 = {

View File

@@ -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()
}

View File

@@ -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<Buffer>
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)

View File

@@ -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<Buffer>(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 */