From db4b3f329fd701c739c61043a266e786033d4cc8 Mon Sep 17 00:00:00 2001 From: vini <91087061+vinikjkkj@users.noreply.github.com> Date: Sun, 28 Jul 2024 11:06:07 -0300 Subject: [PATCH 1/3] feat: full newsletter support (#822) * initial commit * add reactions support * changes * fix type error * add enableLiveUpdates fn / remove reaction handler in decode * add newsletter functions (to test) * add events / other things * fix handler * add extract metadata * add types * add admin events / some fixes * better typing * typing / add some functions (to test) * add parse for fetchMessages * improve parseFetched / typing * finish * lint / fix metadata * better newsletter socket structure * fix / add tos query in newsletterCreate --------- Co-authored-by: Rajeh Taher --- src/Socket/messages-recv.ts | 63 ++++++- src/Socket/messages-send.ts | 19 ++- src/Socket/newsletter.ts | 292 +++++++++++++++++++++++++++++++++ src/Types/Events.ts | 7 + src/Types/Message.ts | 2 +- src/Types/Newsletter.ts | 84 ++++++++++ src/Types/index.ts | 1 + src/Utils/decode-wa-message.ts | 29 ++-- src/Utils/generics.ts | 4 + src/Utils/messages.ts | 9 +- src/WABinary/jid-utils.ts | 4 +- 11 files changed, 493 insertions(+), 21 deletions(-) create mode 100644 src/Socket/newsletter.ts create mode 100644 src/Types/Newsletter.ts diff --git a/src/Socket/messages-recv.ts b/src/Socket/messages-recv.ts index 6e64dd69fa8..7049415d4b8 100644 --- a/src/Socket/messages-recv.ts +++ b/src/Socket/messages-recv.ts @@ -4,7 +4,7 @@ import { randomBytes } from 'crypto' import NodeCache from 'node-cache' import { proto } from '../../WAProto' import { DEFAULT_CACHE_TTLS, KEY_BUNDLE_TYPE, MIN_PREKEY_COUNT } from '../Defaults' -import { MessageReceiptType, MessageRelayOptions, MessageUserReceipt, SocketConfig, WACallEvent, WAMessageKey, WAMessageStatus, WAMessageStubType, WAPatchName } from '../Types' +import { MessageReceiptType, MessageRelayOptions, MessageUserReceipt, MexOperations, NewsletterSettingsUpdate, SocketConfig, WACallEvent, WAMessageKey, WAMessageStatus, WAMessageStubType, WAPatchName, XWAPaths } from '../Types' import { aesDecryptCTR, aesEncryptGCM, @@ -341,6 +341,59 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { } } + const handleNewsletterNotification = (id: string, node: BinaryNode) => { + const messages = getBinaryNodeChild(node, 'messages') + const message = getBinaryNodeChild(messages, 'message')! + + const server_id = message.attrs.server_id + + const reactionsList = getBinaryNodeChild(message, 'reactions') + const viewsList = getBinaryNodeChildren(message, 'views_count') + + if(reactionsList){ + const reactions = getBinaryNodeChildren(reactionsList, 'reaction') + if(reactions.length === 0){ + ev.emit('newsletter.reaction', {id, server_id, reaction: { removed: true }}) + } + reactions.forEach(item => { + ev.emit('newsletter.reaction', {id, server_id, reaction: { code: item.attrs?.code, count: +item.attrs?.count }}) + }) + } + + if(viewsList.length){ + viewsList.forEach(item => { + ev.emit('newsletter.view', {id, server_id, count: +item.attrs.count}) + }) + } + } + + const handleMexNewsletterNotification = (id: string, node: BinaryNode) => { + const operation = node?.attrs.op_name + const content = JSON.parse(node?.content?.toString()!) + + let contentPath + + if(operation === MexOperations.PROMOTE || operation === MexOperations.DEMOTE){ + let action + if(operation === MexOperations.PROMOTE){ + action = 'promote' + contentPath = content.data[XWAPaths.PROMOTE] + } + + if(operation === MexOperations.DEMOTE){ + action = 'demote' + contentPath = content.data[XWAPaths.DEMOTE] + } + + ev.emit('newsletter-participants.update', {id, author: contentPath.actor.pn, user: contentPath.user.pn, new_role: contentPath.user_new_role, action}) + } + + if(operation === MexOperations.UPDATE){ + contentPath = content.data[XWAPaths.METADATA_UPDATE] + ev.emit('newsletter-settings.update', {id, update: contentPath.thread_metadata.settings as NewsletterSettingsUpdate}) + } + } + const processNotification = async(node: BinaryNode) => { const result: Partial = { } const [child] = getAllBinaryNodeChildren(node) @@ -362,6 +415,12 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { logger.debug({ jid }, 'got privacy token update') } + break + case 'newsletter': + handleNewsletterNotification(node.attrs.from, child) + break + case 'mex': + handleMexNewsletterNotification(node.attrs.from, child) break case 'w:gp2': handleGroupNotification(node.attrs.participant, child, result) @@ -814,7 +873,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { } const handleBadAck = async({ attrs }: BinaryNode) => { - const key: WAMessageKey = { remoteJid: attrs.from, fromMe: true, id: attrs.id } + const key: WAMessageKey = { remoteJid: attrs.from, fromMe: true, id: attrs.id, server_id: attrs?.server_id } // current hypothesis is that if pash is sent in the ack // it means -- the message hasn't reached all devices yet // we'll retry sending the message here diff --git a/src/Socket/messages-send.ts b/src/Socket/messages-send.ts index eded651f8ed..b90058b10c9 100644 --- a/src/Socket/messages-send.ts +++ b/src/Socket/messages-send.ts @@ -4,10 +4,11 @@ import NodeCache from 'node-cache' import { proto } from '../../WAProto' import { DEFAULT_CACHE_TTLS, WA_DEFAULT_EPHEMERAL } from '../Defaults' import { AnyMessageContent, MediaConnInfo, MessageReceiptType, MessageRelayOptions, MiscMessageGenerationOptions, SocketConfig, WAMessageKey } from '../Types' -import { aggregateMessageKeysNotFromMe, assertMediaContent, bindWaitForEvent, decryptMediaRetryData, encodeSignedDeviceIdentity, encodeWAMessage, encryptMediaRetryRequest, extractDeviceJids, generateMessageIDV2, generateWAMessage, getStatusCodeForMediaRetry, getUrlFromDirectPath, getWAUploadToServer, parseAndInjectE2ESessions, unixTimestampSeconds } from '../Utils' + +import { aggregateMessageKeysNotFromMe, assertMediaContent, bindWaitForEvent, decryptMediaRetryData, encodeNewsletterMessage, encodeSignedDeviceIdentity, encodeWAMessage, encryptMediaRetryRequest, extractDeviceJids, generateMessageIDV2, generateWAMessage, getStatusCodeForMediaRetry, getUrlFromDirectPath, getWAUploadToServer, parseAndInjectE2ESessions, unixTimestampSeconds } from '../Utils' import { getUrlInfo } from '../Utils/link-preview' import { areJidsSameUser, BinaryNode, BinaryNodeAttributes, getBinaryNodeChild, getBinaryNodeChildren, isJidGroup, isJidUser, jidDecode, jidEncode, jidNormalizedUser, JidWithDevice, S_WHATSAPP_NET } from '../WABinary' -import { makeGroupsSocket } from './groups' +import { makeNewsletterSocket } from './newsletter' import ListType = proto.Message.ListMessage.ListType; export const makeMessagesSocket = (config: SocketConfig) => { @@ -18,7 +19,7 @@ export const makeMessagesSocket = (config: SocketConfig) => { options: axiosOptions, patchMessageBeforeSending, } = config - const sock = makeGroupsSocket(config) + const sock = makeNewsletterSocket(config) const { ev, authState, @@ -315,6 +316,7 @@ export const makeMessagesSocket = (config: SocketConfig) => { const { user, server } = jidDecode(jid)! const statusJid = 'status@broadcast' const isGroup = server === 'g.us' + const isNewsletter = server == 'newsletter' const isStatus = jid === statusJid const isLid = server === 'lid' @@ -322,7 +324,7 @@ export const makeMessagesSocket = (config: SocketConfig) => { useUserDevicesCache = useUserDevicesCache !== false const participants: BinaryNode[] = [] - const destinationJid = (!isStatus) ? jidEncode(user, isLid ? 'lid' : isGroup ? 'g.us' : 's.whatsapp.net') : statusJid + const destinationJid = (!isStatus) ? jidEncode(user, isLid ? 'lid' : isGroup ? 'g.us' : isNewsletter ? 'newsletter' : 's.whatsapp.net') : statusJid const binaryNodeContent: BinaryNode[] = [] const devices: JidWithDevice[] = [] @@ -431,6 +433,15 @@ export const makeMessagesSocket = (config: SocketConfig) => { }) await authState.keys.set({ 'sender-key-memory': { [jid]: senderKeyMap } }) + } else if(isNewsletter){ + const patched = await patchMessageBeforeSending(message, []) + const bytes = encodeNewsletterMessage(patched) + + binaryNodeContent.push({ + tag: 'plaintext', + attrs: {}, + content: bytes + }) } else { const { user: meUser, device: meDevice } = jidDecode(meId)! diff --git a/src/Socket/newsletter.ts b/src/Socket/newsletter.ts new file mode 100644 index 00000000000..d29aaf7f536 --- /dev/null +++ b/src/Socket/newsletter.ts @@ -0,0 +1,292 @@ +import { SocketConfig, WAMediaUpload, NewsletterMetadata, NewsletterReactionMode, NewsletterViewRole, XWAPaths, NewsletterReaction, NewsletterFetchedUpdate } from '../Types' +import { decryptMessageNode, generateMessageID, generateProfilePicture } from '../Utils' +import { BinaryNode, getAllBinaryNodeChildren, getBinaryNodeChild, getBinaryNodeChildren, S_WHATSAPP_NET } from '../WABinary' +import { makeGroupsSocket } from './groups' + +enum QueryIds { + JOB_MUTATION = '7150902998257522', + METADATA = '6620195908089573', + UNFOLLOW = '7238632346214362', + FOLLOW = '7871414976211147', + UNMUTE = '7337137176362961', + MUTE = '25151904754424642', + CREATE = '6996806640408138', + ADMIN_COUNT = '7130823597031706', + CHANGE_OWNER = '7341777602580933', + DELETE = '8316537688363079', + DEMOTE = '6551828931592903' +} + +export const makeNewsletterSocket = (config: SocketConfig) => { + const sock = makeGroupsSocket(config) + const { authState, signalRepository, query, generateMessageTag } = sock + + const encoder = new TextEncoder() + + const newsletterQuery = async(jid: string, type: 'get' | 'set', content: BinaryNode[]) => ( + query({ + tag: 'iq', + attrs: { + id: generateMessageTag(), + type, + xmlns: 'newsletter', + to: jid, + }, + content + }) + ) + + const newsletterWMexQuery = async(jid: string | undefined, query_id: QueryIds, content?: object) => ( + query({ + tag: 'iq', + attrs: { + id: generateMessageTag(), + type: 'get', + xmlns: 'w:mex', + to: S_WHATSAPP_NET, + }, + content: [ + { + tag: 'query', + attrs: {query_id}, + content: encoder.encode(JSON.stringify({ variables: { newsletter_id: jid, ...content } })) + } + ] + }) + ) + + const parseFetchedUpdates = async(node: BinaryNode, type: 'messages' | 'updates') => { + let child + + if(type === 'messages') child = getBinaryNodeChild(node, 'messages') + else{ + const parent = getBinaryNodeChild(node, 'message_updates') + child = getBinaryNodeChild(parent, 'messages') + } + + return await Promise.all(getAllBinaryNodeChildren(child!).map(async messageNode => { + messageNode.attrs.from = child?.attrs.jid as string + + const views = getBinaryNodeChild(messageNode, 'views_count')?.attrs?.count + const reactionNode = getBinaryNodeChild(messageNode, 'reactions') + const reactions = getBinaryNodeChildren(reactionNode, 'reaction') + .map(({ attrs }) => ({count: +attrs.count, code: attrs.code} as NewsletterReaction)) + + let data: NewsletterFetchedUpdate + if(type === 'messages'){ + const { fullMessage: message, decrypt } = await decryptMessageNode( + messageNode, + authState.creds.me!.id, + authState.creds.me!.lid || '', + signalRepository, + config.logger + ) + + await decrypt() + + data = { + server_id: messageNode.attrs.server_id, + views: views ? +views : undefined, + reactions, + message + } + + return data + }else{ + data = { + server_id: messageNode.attrs.server_id, + views: views ? +views : undefined, + reactions + } + + return data + } + + })) + } + + return { + ...sock, + subscribeNewsletterUpdates: async(jid: string) => { + const result = await newsletterQuery(jid, 'set', [{tag: 'live_updates', attrs: {}, content: []}]) + + return getBinaryNodeChild(result, 'live_updates')?.attrs as {duration: string} + }, + + newsletterReactionMode: async(jid: string, mode: NewsletterReactionMode) => { + await newsletterWMexQuery(jid, QueryIds.JOB_MUTATION, { + updates: {settings: {reaction_codes: {value: mode}}} + }) + }, + + newsletterUpdateDescription: async(jid: string, description?: string) => { + await newsletterWMexQuery(jid, QueryIds.JOB_MUTATION, { + updates: {description: description || '', settings: null} + }) + }, + + newsletterUpdateName: async(jid: string, name: string) => { + await newsletterWMexQuery(jid, QueryIds.JOB_MUTATION, { + updates: {name, settings: null} + }) + }, + + newsletterUpdatePicture: async(jid: string, content: WAMediaUpload) => { + const { img } = await generateProfilePicture(content) + + await newsletterWMexQuery(jid, QueryIds.JOB_MUTATION, { + updates: {picture: img.toString('base64'), settings: null} + }) + }, + + newsletterRemovePicture: async(jid: string) => { + await newsletterWMexQuery(jid, QueryIds.JOB_MUTATION, { + updates: {picture: '', settings: null} + }) + }, + + newsletterUnfollow: async(jid: string) => { + await newsletterWMexQuery(jid, QueryIds.UNFOLLOW) + }, + + newsletterFollow: async(jid: string) => { + await newsletterWMexQuery(jid, QueryIds.FOLLOW) + }, + + newsletterUnmute: async(jid: string) => { + await newsletterWMexQuery(jid, QueryIds.UNMUTE) + }, + + newsletterMute: async(jid: string) => { + await newsletterWMexQuery(jid, QueryIds.MUTE) + }, + + newsletterCreate: async(name: string, description: string) => { + /**tos query */ + await query({ + tag: 'iq', + attrs: { + to: S_WHATSAPP_NET, + xmlns: 'tos', + id: generateMessageTag(), + type: 'set' + }, + content: [ + { + tag: 'notice', + attrs: { + id: '20601218', + stage: '5' + }, + content: [] + } + ] + }) + const result = await newsletterWMexQuery(undefined, QueryIds.CREATE, { + input: {name, description} + }) + + return extractNewsletterMetadata(result, true) + }, + + newsletterMetadata: async(type: 'invite' | 'jid', key: string, role?: NewsletterViewRole) => { + const result = await newsletterWMexQuery(undefined, QueryIds.METADATA, { + input: { + key, + type: type.toUpperCase(), + view_role: role || 'GUEST' + }, + fetch_viewer_metadata: true, + fetch_full_image: true, + fetch_creation_time: true + }) + + return extractNewsletterMetadata(result) + }, + + newsletterAdminCount: async(jid: string) => { + const result = await newsletterWMexQuery(jid, QueryIds.ADMIN_COUNT) + + const buff = getBinaryNodeChild(result, 'result')?.content?.toString() + + return JSON.parse(buff!).data[XWAPaths.ADMIN_COUNT].admin_count as number + }, + + /**user is Lid, not Jid */ + newsletterChangeOwner: async(jid: string, user: string) => { + await newsletterWMexQuery(jid, QueryIds.CHANGE_OWNER, { + user_id: user + }) + }, + + /**user is Lid, not Jid */ + newsletterDemote: async(jid: string, user: string) => { + await newsletterWMexQuery(jid, QueryIds.DEMOTE, { + user_id: user + }) + }, + + newsletterDelete: async(jid: string) => { + await newsletterWMexQuery(jid, QueryIds.DELETE) + }, + + /**if code wasn't passed, the reaction will be removed (if is reacted) */ + newsletterReactMessage: async(jid: string, server_id: string, code?: string) => { + await query({ + tag: 'message', + attrs: {to: jid, ...(!code ? {edit: '7'} : {}), type: 'reaction', server_id, id: generateMessageID()}, + content: [{ + tag: 'reaction', + attrs: code ? {code} : {} + }] + }) + }, + + newsletterFetchMessages: async(type: 'invite' | 'jid', key: string, count: number, after?: number) => { + const result = await newsletterQuery(S_WHATSAPP_NET, 'get', [ + { + tag: 'messages', + attrs: {type, ...(type === 'invite' ? {key} : {jid: key}), count: count.toString(), after: after?.toString() || '100'} + } + ]) + + return await parseFetchedUpdates(result, 'messages') + }, + + newsletterFetchUpdates: async(jid: string, count: number, after?: number, since?: number) => { + const result = await newsletterQuery(jid, 'get', [ + { + tag: 'message_updates', + attrs: {count: count.toString(), after: after?.toString() || '100', since: since?.toString() || '0'} + } + ]) + + return await parseFetchedUpdates(result, 'updates') + } + } +} + +export const extractNewsletterMetadata = (node: BinaryNode, isCreate?: boolean) => { + const result = getBinaryNodeChild(node, 'result')?.content?.toString() + const metadataPath = JSON.parse(result!).data[isCreate ? XWAPaths.CREATE : XWAPaths.NEWSLETTER] + + const metadata: NewsletterMetadata = { + id: metadataPath.id, + state: metadataPath.state.type, + creation_time: +metadataPath.thread_metadata.creation_time, + name: metadataPath.thread_metadata.name.text, + nameTime: +metadataPath.thread_metadata.name.update_time, + description: metadataPath.thread_metadata.description.text, + descriptionTime: +metadataPath.thread_metadata.description.update_time, + invite: metadataPath.thread_metadata.invite, + handle: metadataPath.thread_metadata.handle, + picture: metadataPath.thread_metadata.picture.direct_path || null, + preview: metadataPath.thread_metadata.preview.direct_path || null, + reaction_codes: metadataPath.thread_metadata?.settings?.reaction_codes?.value, + subscribers: +metadataPath.thread_metadata.subscribers_count, + verification: metadataPath.thread_metadata.verification, + viewer_metadata: metadataPath.viewer_metadata + } + + return metadata +} \ No newline at end of file diff --git a/src/Types/Events.ts b/src/Types/Events.ts index e10aad74f74..04ccff39792 100644 --- a/src/Types/Events.ts +++ b/src/Types/Events.ts @@ -9,6 +9,7 @@ import { Label } from './Label' import { LabelAssociation } from './LabelAssociation' import { MessageUpsertType, MessageUserReceiptUpdate, WAMessage, WAMessageKey, WAMessageUpdate } from './Message' import { ConnectionState } from './State' +import { NewsletterSettingsUpdate, SubscriberAction, NewsletterViewRole } from './Newsletter' export type BaileysEventMap = { /** connection state has been updated -- WS closed, opened, connecting etc. */ @@ -54,6 +55,12 @@ export type BaileysEventMap = { 'group-participants.update': { id: string, author: string, participants: string[], action: ParticipantAction } 'group.join-request': { id: string, author: string, participant: string, action: RequestJoinAction, method: RequestJoinMethod } + 'newsletter.reaction': { id: string, server_id: string, reaction: {code?: string, count?: number, removed?: boolean}} + 'newsletter.view': { id: string, server_id: string, count: number} + /**don't handles subscribe/unsubscribe actions */ + 'newsletter-participants.update': { id: string, author: string, user: string, new_role: NewsletterViewRole, action: SubscriberAction} + 'newsletter-settings.update': { id: string, update: NewsletterSettingsUpdate} + 'blocklist.set': { blocklist: string[] } 'blocklist.update': { blocklist: string[], type: 'add' | 'remove' } diff --git a/src/Types/Message.ts b/src/Types/Message.ts index 62d2f8b0304..e8f3bbcb40e 100644 --- a/src/Types/Message.ts +++ b/src/Types/Message.ts @@ -13,7 +13,7 @@ export type WAMessage = proto.IWebMessageInfo export type WAMessageContent = proto.IMessage export type WAContactMessage = proto.Message.IContactMessage export type WAContactsArrayMessage = proto.Message.IContactsArrayMessage -export type WAMessageKey = proto.IMessageKey +export type WAMessageKey = proto.IMessageKey & {server_id?: string} export type WATextMessage = proto.Message.IExtendedTextMessage export type WAContextInfo = proto.IContextInfo export type WALocationMessage = proto.Message.ILocationMessage diff --git a/src/Types/Newsletter.ts b/src/Types/Newsletter.ts new file mode 100644 index 00000000000..a786d3966d4 --- /dev/null +++ b/src/Types/Newsletter.ts @@ -0,0 +1,84 @@ +import { proto } from '../../WAProto' + +export type NewsletterReactionMode = 'ALL' | 'BASIC' | 'NONE' + +export type NewsletterState = 'ACTIVE' | 'GEOSUSPENDED' | 'SUSPENDED' + +export type NewsletterVerification = 'VERIFIED' | 'UNVERIFIED' + +export type NewsletterMute = 'ON' | 'OFF' | 'UNDEFINED' + +export type NewsletterViewRole = 'ADMIN' | 'GUEST' | 'OWNER' | 'SUBSCRIBER' + +export type NewsletterViewerMetadata = { + mute: NewsletterMute, + view_role: NewsletterViewRole +} + +export type NewsletterMetadata = { + /**jid of newsletter */ + id: string, + /**state of newsletter */ + state: NewsletterState, + /**creation timestamp of newsletter */ + creation_time: number, + /**name of newsletter */ + name: string, + /**timestamp of last name modification of newsletter */ + nameTime: number, + /**description of newsletter */ + description: string, + /**timestamp of last description modification of newsletter */ + descriptionTime: number, + /**invite code of newsletter */ + invite: string, + /**i dont know */ + handle: null, + /**direct path of picture */ + picture: string | null, + /**direct path of picture preview (lower quality) */ + preview: string | null, + /**reaction mode of newsletter */ + reaction_codes?: NewsletterReactionMode, + /**subscribers count of newsletter */ + subscribers: number, + /**verification state of newsletter */ + verification: NewsletterVerification, + /**viewer metadata */ + viewer_metadata: NewsletterViewerMetadata +} + +export type SubscriberAction = 'promote' | 'demote' + +export type ReactionModeUpdate = {reaction_codes: {blocked_codes: null, enabled_ts_sec: null, value: NewsletterReactionMode}} + +/**only exists reaction mode update */ +export type NewsletterSettingsUpdate = ReactionModeUpdate + +export type NewsletterReaction = {count: number, code: string} + +export type NewsletterFetchedUpdate = { + /**id of message in newsletter, starts from 100 */ + server_id: string, + /**count of views in this message */ + views?: number, + /**reactions in this message */ + reactions: NewsletterReaction[], + /**the message, if you requested only updates, you will not receive message */ + message?: proto.IWebMessageInfo +} + +export enum MexOperations{ + PROMOTE = 'NotificationNewsletterAdminPromote', + DEMOTE = 'NotificationNewsletterAdminDemote', + UPDATE = 'NotificationNewsletterUpdate' +} + +export enum XWAPaths{ + PROMOTE = 'xwa2_notify_newsletter_admin_promote', + DEMOTE = 'xwa2_notify_newsletter_admin_demote', + ADMIN_COUNT = 'xwa2_newsletter_admin', + CREATE = 'xwa2_newsletter_create', + NEWSLETTER = 'xwa2_newsletter', + METADATA_UPDATE = 'xwa2_notify_newsletter_on_metadata_update' +} \ No newline at end of file diff --git a/src/Types/index.ts b/src/Types/index.ts index fe50887e81c..3e96000e61e 100644 --- a/src/Types/index.ts +++ b/src/Types/index.ts @@ -1,5 +1,6 @@ export * from './Auth' export * from './GroupMetadata' +export * from './Newsletter' export * from './Chat' export * from './Contact' export * from './State' diff --git a/src/Utils/decode-wa-message.ts b/src/Utils/decode-wa-message.ts index 3760865c73a..976503a1342 100644 --- a/src/Utils/decode-wa-message.ts +++ b/src/Utils/decode-wa-message.ts @@ -2,12 +2,12 @@ import { Boom } from '@hapi/boom' import { Logger } from 'pino' import { proto } from '../../WAProto' import { SignalRepository, WAMessageKey } from '../Types' -import { areJidsSameUser, BinaryNode, isJidBroadcast, isJidGroup, isJidStatusBroadcast, isJidUser, isLidUser } from '../WABinary' +import { areJidsSameUser, BinaryNode, isJidBroadcast, isJidGroup, isJidNewsletter, isJidStatusBroadcast, isJidUser, isLidUser } from '../WABinary' import { BufferJSON, unpadRandomMax16 } from './generics' const NO_MESSAGE_FOUND_ERROR_TEXT = 'Message absent from node' -type MessageType = 'chat' | 'peer_broadcast' | 'other_broadcast' | 'group' | 'direct_peer_status' | 'other_status' +type MessageType = 'chat' | 'peer_broadcast' | 'other_broadcast' | 'group' | 'direct_peer_status' | 'other_status' | 'newsletter' /** * Decode the received node as a message. @@ -64,6 +64,10 @@ export function decodeMessageNode( msgType = 'group' author = participant chatId = from + } else if(isJidNewsletter(from)){ + msgType = 'newsletter' + author = from + chatId = from } else if(isJidBroadcast(from)) { if(!participant) { throw new Boom('No participant in group message') @@ -82,14 +86,15 @@ export function decodeMessageNode( throw new Boom('Unknown message type', { data: stanza }) } - const fromMe = (isLidUser(from) ? isMeLid : isMe)(stanza.attrs.participant || stanza.attrs.from) - const pushname = stanza.attrs.notify + const fromMe = isJidNewsletter(from) ? !!stanza.attrs?.is_sender || false : (isLidUser(from) ? isMeLid : isMe)(stanza.attrs.participant || stanza.attrs.from) + const pushname = stanza?.attrs?.notify const key: WAMessageKey = { remoteJid: chatId, fromMe, id: msgId, - participant + participant, + server_id: stanza.attrs?.server_id } const fullMessage: proto.IWebMessageInfo = { @@ -132,7 +137,7 @@ export const decryptMessageNode = ( fullMessage.verifiedBizName = details.verifiedName } - if(tag !== 'enc') { + if(tag !== 'enc' && tag !== 'plaintext') { continue } @@ -163,21 +168,25 @@ export const decryptMessageNode = ( ciphertext: content }) break + case undefined: + msgBuffer = content + break default: throw new Error(`Unknown e2e type: ${e2eType}`) } - let msg: proto.IMessage = proto.Message.decode(unpadRandomMax16(msgBuffer)) - msg = msg.deviceSentMessage?.message || msg + let msg: proto.IMessage = proto.Message.decode(tag === 'plaintext' ? msgBuffer : unpadRandomMax16(msgBuffer)) + msg = msg?.deviceSentMessage?.message || msg + if(msg.senderKeyDistributionMessage) { - try { + try { await repository.processSenderKeyDistributionMessage({ authorJid: author, item: msg.senderKeyDistributionMessage }) } catch(err) { logger.error({ key: fullMessage.key, err }, 'failed to decrypt message') - } + } } if(fullMessage.message) { diff --git a/src/Utils/generics.ts b/src/Utils/generics.ts index c99dbb43ee9..94a18cf9077 100644 --- a/src/Utils/generics.ts +++ b/src/Utils/generics.ts @@ -87,6 +87,10 @@ export const encodeWAMessage = (message: proto.IMessage) => ( ) ) +export const encodeNewsletterMessage = (message: proto.IMessage) => ( + proto.Message.encode(message).finish() +) + export const generateRegistrationId = (): number => { return Uint16Array.from(randomBytes(2))[0] & 16383 } diff --git a/src/Utils/messages.ts b/src/Utils/messages.ts index 12360917d86..9a8237acddf 100644 --- a/src/Utils/messages.ts +++ b/src/Utils/messages.ts @@ -24,7 +24,7 @@ import { WAProto, WATextMessage, } from '../Types' -import { isJidGroup, isJidStatusBroadcast, jidNormalizedUser } from '../WABinary' +import { isJidGroup, isJidNewsletter, isJidStatusBroadcast, jidNormalizedUser } from '../WABinary' import { sha256 } from './crypto' import { generateMessageID, getKeyAuthor, unixTimestampSeconds } from './generics' import { downloadContentFromMessage, encryptedStream, generateThumbnail, getAudioDuration, getAudioWaveform, MediaDownloadOptions } from './messages-media' @@ -583,7 +583,8 @@ export const generateWAMessageFromContent = ( const timestamp = unixTimestampSeconds(options.timestamp) const { quoted, userJid } = options - if(quoted) { + // only set quoted if isn't a newsletter message + if(quoted && !isJidNewsletter(jid)) { const participant = quoted.key.fromMe ? userJid : (quoted.participant || quoted.key.participant || quoted.key.remoteJid) let quotedMsg = normalizeMessageContent(quoted.message)! @@ -616,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' && + // newsletter not accept disappearing messages + !isJidNewsletter(jid) ) { innerMessage[key].contextInfo = { ...(innerMessage[key].contextInfo || {}), diff --git a/src/WABinary/jid-utils.ts b/src/WABinary/jid-utils.ts index cb58eefd7a4..1fdcae0e920 100644 --- a/src/WABinary/jid-utils.ts +++ b/src/WABinary/jid-utils.ts @@ -4,7 +4,7 @@ export const SERVER_JID = 'server@c.us' export const PSA_WID = '0@c.us' export const STORIES_JID = 'status@broadcast' -export type JidServer = 'c.us' | 'g.us' | 'broadcast' | 's.whatsapp.net' | 'call' | 'lid' +export type JidServer = 'c.us' | 'g.us' | 'broadcast' | 's.whatsapp.net' | 'call' | 'lid' | 'newsletter' export type JidWithDevice = { user: string @@ -52,6 +52,8 @@ export const isLidUser = (jid: string | undefined) => (jid?.endsWith('@lid')) export const isJidBroadcast = (jid: string | undefined) => (jid?.endsWith('@broadcast')) /** is the jid a group */ export const isJidGroup = (jid: string | undefined) => (jid?.endsWith('@g.us')) +/** is the jid a newsletter */ +export const isJidNewsletter = (jid: string | undefined) => (jid?.endsWith('@newsletter')) /** is the jid the status broadcast */ export const isJidStatusBroadcast = (jid: string) => jid === 'status@broadcast' From a37d7b0cd779ed94d8860e33d2b584512a60a362 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 28 Jul 2024 17:19:18 +0300 Subject: [PATCH 2/3] fix(feature/newsletter): Linting --- .eslintrc.json | 7 - src/Socket/messages-recv.ts | 47 ++-- src/Socket/messages-send.ts | 7 +- src/Socket/newsletter.ts | 483 +++++++++++++++++---------------- src/Types/Events.ts | 2 +- src/Types/Newsletter.ts | 40 +-- src/Utils/decode-wa-message.ts | 19 +- src/Utils/messages.ts | 4 +- src/WABinary/jid-utils.ts | 4 +- 9 files changed, 305 insertions(+), 308 deletions(-) diff --git a/.eslintrc.json b/.eslintrc.json index 258042d2df7..d5c41ceac5b 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -21,13 +21,6 @@ "@typescript-eslint/no-unnecessary-type-assertion": [ "warn" ], - "no-restricted-syntax": [ - "warn", - { - "selector": "TSEnumDeclaration", - "message": "Don't declare enums, use literals instead" - } - ], "keyword-spacing": [ "warn" ] diff --git a/src/Socket/messages-recv.ts b/src/Socket/messages-recv.ts index 7049415d4b8..3c2143870d2 100644 --- a/src/Socket/messages-recv.ts +++ b/src/Socket/messages-recv.ts @@ -322,7 +322,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { break case 'membership_approval_mode': - const approvalMode: any = getBinaryNodeChild(child, 'group_join') + const approvalMode = getBinaryNodeChild(child, 'group_join') if(approvalMode) { msg.messageStubType = WAMessageStubType.GROUP_MEMBERSHIP_JOIN_APPROVAL_MODE msg.messageStubParameters = [ approvalMode.attrs.state ] @@ -342,29 +342,30 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { } const handleNewsletterNotification = (id: string, node: BinaryNode) => { - const messages = getBinaryNodeChild(node, 'messages') - const message = getBinaryNodeChild(messages, 'message')! + const messages = getBinaryNodeChild(node, 'messages') + const message = getBinaryNodeChild(messages, 'message')! - const server_id = message.attrs.server_id + const serverId = message.attrs.server_id - const reactionsList = getBinaryNodeChild(message, 'reactions') + const reactionsList = getBinaryNodeChild(message, 'reactions') const viewsList = getBinaryNodeChildren(message, 'views_count') - if(reactionsList){ + if(reactionsList) { const reactions = getBinaryNodeChildren(reactionsList, 'reaction') - if(reactions.length === 0){ - ev.emit('newsletter.reaction', {id, server_id, reaction: { removed: true }}) + if(reactions.length === 0) { + ev.emit('newsletter.reaction', { id, 'server_id': serverId, reaction: { removed: true } }) } + reactions.forEach(item => { - ev.emit('newsletter.reaction', {id, server_id, reaction: { code: item.attrs?.code, count: +item.attrs?.count }}) + ev.emit('newsletter.reaction', { id, 'server_id': serverId, reaction: { code: item.attrs?.code, count: +item.attrs?.count } }) }) - } + } - if(viewsList.length){ + if(viewsList.length) { viewsList.forEach(item => { - ev.emit('newsletter.view', {id, server_id, count: +item.attrs.count}) + ev.emit('newsletter.view', { id, 'server_id': serverId, count: +item.attrs.count }) }) - } + } } const handleMexNewsletterNotification = (id: string, node: BinaryNode) => { @@ -373,24 +374,24 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { let contentPath - if(operation === MexOperations.PROMOTE || operation === MexOperations.DEMOTE){ + if(operation === MexOperations.PROMOTE || operation === MexOperations.DEMOTE) { let action - if(operation === MexOperations.PROMOTE){ + if(operation === MexOperations.PROMOTE) { action = 'promote' contentPath = content.data[XWAPaths.PROMOTE] } - - if(operation === MexOperations.DEMOTE){ + + if(operation === MexOperations.DEMOTE) { action = 'demote' contentPath = content.data[XWAPaths.DEMOTE] } - ev.emit('newsletter-participants.update', {id, author: contentPath.actor.pn, user: contentPath.user.pn, new_role: contentPath.user_new_role, action}) + ev.emit('newsletter-participants.update', { id, author: contentPath.actor.pn, user: contentPath.user.pn, 'new_role': contentPath.user_new_role, action }) } - if(operation === MexOperations.UPDATE){ + if(operation === MexOperations.UPDATE) { contentPath = content.data[XWAPaths.METADATA_UPDATE] - ev.emit('newsletter-settings.update', {id, update: contentPath.thread_metadata.settings as NewsletterSettingsUpdate}) + ev.emit('newsletter-settings.update', { id, update: contentPath.thread_metadata.settings as NewsletterSettingsUpdate }) } } @@ -418,7 +419,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { break case 'newsletter': handleNewsletterNotification(node.attrs.from, child) - break + break case 'mex': handleMexNewsletterNotification(node.attrs.from, child) break @@ -758,7 +759,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { } const handleMessage = async(node: BinaryNode) => { - if(shouldIgnoreJid(node.attrs.from!) && node.attrs.from! !== '@s.whatsapp.net') { + if(shouldIgnoreJid(node.attrs.from) && node.attrs.from !== '@s.whatsapp.net') { logger.debug({ key: node.attrs.key }, 'ignored message') await sendMessageAck(node) return @@ -873,7 +874,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { } const handleBadAck = async({ attrs }: BinaryNode) => { - const key: WAMessageKey = { remoteJid: attrs.from, fromMe: true, id: attrs.id, server_id: attrs?.server_id } + const key: WAMessageKey = { remoteJid: attrs.from, fromMe: true, id: attrs.id, 'server_id': attrs?.server_id } // current hypothesis is that if pash is sent in the ack // it means -- the message hasn't reached all devices yet // we'll retry sending the message here diff --git a/src/Socket/messages-send.ts b/src/Socket/messages-send.ts index b90058b10c9..b59ffa48dd8 100644 --- a/src/Socket/messages-send.ts +++ b/src/Socket/messages-send.ts @@ -4,7 +4,6 @@ import NodeCache from 'node-cache' import { proto } from '../../WAProto' import { DEFAULT_CACHE_TTLS, WA_DEFAULT_EPHEMERAL } from '../Defaults' import { AnyMessageContent, MediaConnInfo, MessageReceiptType, MessageRelayOptions, MiscMessageGenerationOptions, SocketConfig, WAMessageKey } from '../Types' - import { aggregateMessageKeysNotFromMe, assertMediaContent, bindWaitForEvent, decryptMediaRetryData, encodeNewsletterMessage, encodeSignedDeviceIdentity, encodeWAMessage, encryptMediaRetryRequest, extractDeviceJids, generateMessageIDV2, generateWAMessage, getStatusCodeForMediaRetry, getUrlFromDirectPath, getWAUploadToServer, parseAndInjectE2ESessions, unixTimestampSeconds } from '../Utils' import { getUrlInfo } from '../Utils/link-preview' import { areJidsSameUser, BinaryNode, BinaryNodeAttributes, getBinaryNodeChild, getBinaryNodeChildren, isJidGroup, isJidUser, jidDecode, jidEncode, jidNormalizedUser, JidWithDevice, S_WHATSAPP_NET } from '../WABinary' @@ -316,7 +315,7 @@ export const makeMessagesSocket = (config: SocketConfig) => { const { user, server } = jidDecode(jid)! const statusJid = 'status@broadcast' const isGroup = server === 'g.us' - const isNewsletter = server == 'newsletter' + const isNewsletter = server === 'newsletter' const isStatus = jid === statusJid const isLid = server === 'lid' @@ -433,7 +432,7 @@ export const makeMessagesSocket = (config: SocketConfig) => { }) await authState.keys.set({ 'sender-key-memory': { [jid]: senderKeyMap } }) - } else if(isNewsletter){ + } else if(isNewsletter) { const patched = await patchMessageBeforeSending(message, []) const bytes = encodeNewsletterMessage(patched) @@ -690,7 +689,7 @@ export const makeMessagesSocket = (config: SocketConfig) => { } content.directPath = media.directPath - content.url = getUrlFromDirectPath(content.directPath!) + content.url = getUrlFromDirectPath(content.directPath) logger.debug({ directPath: media.directPath, key: result.key }, 'media update successful') } catch(err) { diff --git a/src/Socket/newsletter.ts b/src/Socket/newsletter.ts index d29aaf7f536..d3b8683f45a 100644 --- a/src/Socket/newsletter.ts +++ b/src/Socket/newsletter.ts @@ -1,4 +1,4 @@ -import { SocketConfig, WAMediaUpload, NewsletterMetadata, NewsletterReactionMode, NewsletterViewRole, XWAPaths, NewsletterReaction, NewsletterFetchedUpdate } from '../Types' +import { NewsletterFetchedUpdate, NewsletterMetadata, NewsletterReaction, NewsletterReactionMode, NewsletterViewRole, SocketConfig, WAMediaUpload, XWAPaths } from '../Types' import { decryptMessageNode, generateMessageID, generateProfilePicture } from '../Utils' import { BinaryNode, getAllBinaryNodeChildren, getBinaryNodeChild, getBinaryNodeChildren, S_WHATSAPP_NET } from '../WABinary' import { makeGroupsSocket } from './groups' @@ -21,13 +21,13 @@ export const makeNewsletterSocket = (config: SocketConfig) => { const sock = makeGroupsSocket(config) const { authState, signalRepository, query, generateMessageTag } = sock - const encoder = new TextEncoder() + const encoder = new TextEncoder() const newsletterQuery = async(jid: string, type: 'get' | 'set', content: BinaryNode[]) => ( query({ tag: 'iq', attrs: { - id: generateMessageTag(), + id: generateMessageTag(), type, xmlns: 'newsletter', to: jid, @@ -36,257 +36,258 @@ export const makeNewsletterSocket = (config: SocketConfig) => { }) ) - const newsletterWMexQuery = async(jid: string | undefined, query_id: QueryIds, content?: object) => ( - query({ + const newsletterWMexQuery = async(jid: string | undefined, queryId: QueryIds, content?: object) => ( + query({ tag: 'iq', attrs: { - id: generateMessageTag(), + id: generateMessageTag(), type: 'get', xmlns: 'w:mex', to: S_WHATSAPP_NET, }, content: [ - { - tag: 'query', - attrs: {query_id}, - content: encoder.encode(JSON.stringify({ variables: { newsletter_id: jid, ...content } })) - } - ] + { + tag: 'query', + attrs: { 'query_id': queryId }, + content: encoder.encode(JSON.stringify({ variables: { 'newsletter_id': jid, ...content } })) + } + ] }) - ) - - const parseFetchedUpdates = async(node: BinaryNode, type: 'messages' | 'updates') => { - let child - - if(type === 'messages') child = getBinaryNodeChild(node, 'messages') - else{ - const parent = getBinaryNodeChild(node, 'message_updates') - child = getBinaryNodeChild(parent, 'messages') - } - - return await Promise.all(getAllBinaryNodeChildren(child!).map(async messageNode => { - messageNode.attrs.from = child?.attrs.jid as string - - const views = getBinaryNodeChild(messageNode, 'views_count')?.attrs?.count - const reactionNode = getBinaryNodeChild(messageNode, 'reactions') - const reactions = getBinaryNodeChildren(reactionNode, 'reaction') - .map(({ attrs }) => ({count: +attrs.count, code: attrs.code} as NewsletterReaction)) - - let data: NewsletterFetchedUpdate - if(type === 'messages'){ - const { fullMessage: message, decrypt } = await decryptMessageNode( - messageNode, + ) + + const parseFetchedUpdates = async(node: BinaryNode, type: 'messages' | 'updates') => { + let child + + if(type === 'messages') { + child = getBinaryNodeChild(node, 'messages') + } else { + const parent = getBinaryNodeChild(node, 'message_updates') + child = getBinaryNodeChild(parent, 'messages') + } + + return await Promise.all(getAllBinaryNodeChildren(child).map(async messageNode => { + messageNode.attrs.from = child?.attrs.jid as string + + const views = getBinaryNodeChild(messageNode, 'views_count')?.attrs?.count + const reactionNode = getBinaryNodeChild(messageNode, 'reactions') + const reactions = getBinaryNodeChildren(reactionNode, 'reaction') + .map(({ attrs }) => ({ count: +attrs.count, code: attrs.code } as NewsletterReaction)) + + let data: NewsletterFetchedUpdate + if(type === 'messages') { + const { fullMessage: message, decrypt } = await decryptMessageNode( + messageNode, authState.creds.me!.id, authState.creds.me!.lid || '', signalRepository, config.logger - ) - - await decrypt() - - data = { - server_id: messageNode.attrs.server_id, - views: views ? +views : undefined, - reactions, - message - } - - return data - }else{ - data = { - server_id: messageNode.attrs.server_id, - views: views ? +views : undefined, - reactions - } - - return data - } - - })) - } - - return { - ...sock, - subscribeNewsletterUpdates: async(jid: string) => { - const result = await newsletterQuery(jid, 'set', [{tag: 'live_updates', attrs: {}, content: []}]) - - return getBinaryNodeChild(result, 'live_updates')?.attrs as {duration: string} - }, - - newsletterReactionMode: async(jid: string, mode: NewsletterReactionMode) => { - await newsletterWMexQuery(jid, QueryIds.JOB_MUTATION, { - updates: {settings: {reaction_codes: {value: mode}}} - }) - }, - - newsletterUpdateDescription: async(jid: string, description?: string) => { - await newsletterWMexQuery(jid, QueryIds.JOB_MUTATION, { - updates: {description: description || '', settings: null} - }) - }, - - newsletterUpdateName: async(jid: string, name: string) => { - await newsletterWMexQuery(jid, QueryIds.JOB_MUTATION, { - updates: {name, settings: null} - }) - }, - - newsletterUpdatePicture: async(jid: string, content: WAMediaUpload) => { - const { img } = await generateProfilePicture(content) - - await newsletterWMexQuery(jid, QueryIds.JOB_MUTATION, { - updates: {picture: img.toString('base64'), settings: null} - }) - }, - - newsletterRemovePicture: async(jid: string) => { - await newsletterWMexQuery(jid, QueryIds.JOB_MUTATION, { - updates: {picture: '', settings: null} - }) - }, - - newsletterUnfollow: async(jid: string) => { - await newsletterWMexQuery(jid, QueryIds.UNFOLLOW) - }, - - newsletterFollow: async(jid: string) => { - await newsletterWMexQuery(jid, QueryIds.FOLLOW) - }, - - newsletterUnmute: async(jid: string) => { - await newsletterWMexQuery(jid, QueryIds.UNMUTE) - }, - - newsletterMute: async(jid: string) => { - await newsletterWMexQuery(jid, QueryIds.MUTE) - }, - - newsletterCreate: async(name: string, description: string) => { - /**tos query */ - await query({ - tag: 'iq', - attrs: { - to: S_WHATSAPP_NET, - xmlns: 'tos', - id: generateMessageTag(), - type: 'set' - }, - content: [ - { - tag: 'notice', - attrs: { - id: '20601218', - stage: '5' - }, - content: [] - } - ] - }) - const result = await newsletterWMexQuery(undefined, QueryIds.CREATE, { - input: {name, description} - }) - - return extractNewsletterMetadata(result, true) - }, - - newsletterMetadata: async(type: 'invite' | 'jid', key: string, role?: NewsletterViewRole) => { - const result = await newsletterWMexQuery(undefined, QueryIds.METADATA, { - input: { - key, - type: type.toUpperCase(), - view_role: role || 'GUEST' - }, - fetch_viewer_metadata: true, - fetch_full_image: true, - fetch_creation_time: true - }) - - return extractNewsletterMetadata(result) - }, - - newsletterAdminCount: async(jid: string) => { - const result = await newsletterWMexQuery(jid, QueryIds.ADMIN_COUNT) - - const buff = getBinaryNodeChild(result, 'result')?.content?.toString() - - return JSON.parse(buff!).data[XWAPaths.ADMIN_COUNT].admin_count as number - }, - - /**user is Lid, not Jid */ - newsletterChangeOwner: async(jid: string, user: string) => { - await newsletterWMexQuery(jid, QueryIds.CHANGE_OWNER, { - user_id: user - }) - }, - - /**user is Lid, not Jid */ - newsletterDemote: async(jid: string, user: string) => { - await newsletterWMexQuery(jid, QueryIds.DEMOTE, { - user_id: user - }) - }, - - newsletterDelete: async(jid: string) => { - await newsletterWMexQuery(jid, QueryIds.DELETE) - }, - - /**if code wasn't passed, the reaction will be removed (if is reacted) */ - newsletterReactMessage: async(jid: string, server_id: string, code?: string) => { - await query({ - tag: 'message', - attrs: {to: jid, ...(!code ? {edit: '7'} : {}), type: 'reaction', server_id, id: generateMessageID()}, - content: [{ - tag: 'reaction', - attrs: code ? {code} : {} - }] - }) - }, - - newsletterFetchMessages: async(type: 'invite' | 'jid', key: string, count: number, after?: number) => { - const result = await newsletterQuery(S_WHATSAPP_NET, 'get', [ - { - tag: 'messages', - attrs: {type, ...(type === 'invite' ? {key} : {jid: key}), count: count.toString(), after: after?.toString() || '100'} - } - ]) - - return await parseFetchedUpdates(result, 'messages') - }, - - newsletterFetchUpdates: async(jid: string, count: number, after?: number, since?: number) => { - const result = await newsletterQuery(jid, 'get', [ - { - tag: 'message_updates', - attrs: {count: count.toString(), after: after?.toString() || '100', since: since?.toString() || '0'} - } - ]) - - return await parseFetchedUpdates(result, 'updates') - } - } + ) + + await decrypt() + + data = { + 'server_id': messageNode.attrs.server_id, + views: views ? +views : undefined, + reactions, + message + } + + return data + } else { + data = { + 'server_id': messageNode.attrs.server_id, + views: views ? +views : undefined, + reactions + } + + return data + } + + })) + } + + return { + ...sock, + subscribeNewsletterUpdates: async(jid: string) => { + const result = await newsletterQuery(jid, 'set', [{ tag: 'live_updates', attrs: {}, content: [] }]) + + return getBinaryNodeChild(result, 'live_updates')?.attrs as {duration: string} + }, + + newsletterReactionMode: async(jid: string, mode: NewsletterReactionMode) => { + await newsletterWMexQuery(jid, QueryIds.JOB_MUTATION, { + updates: { settings: { 'reaction_codes': { value: mode } } } + }) + }, + + newsletterUpdateDescription: async(jid: string, description?: string) => { + await newsletterWMexQuery(jid, QueryIds.JOB_MUTATION, { + updates: { description: description || '', settings: null } + }) + }, + + newsletterUpdateName: async(jid: string, name: string) => { + await newsletterWMexQuery(jid, QueryIds.JOB_MUTATION, { + updates: { name, settings: null } + }) + }, + + newsletterUpdatePicture: async(jid: string, content: WAMediaUpload) => { + const { img } = await generateProfilePicture(content) + + await newsletterWMexQuery(jid, QueryIds.JOB_MUTATION, { + updates: { picture: img.toString('base64'), settings: null } + }) + }, + + newsletterRemovePicture: async(jid: string) => { + await newsletterWMexQuery(jid, QueryIds.JOB_MUTATION, { + updates: { picture: '', settings: null } + }) + }, + + newsletterUnfollow: async(jid: string) => { + await newsletterWMexQuery(jid, QueryIds.UNFOLLOW) + }, + + newsletterFollow: async(jid: string) => { + await newsletterWMexQuery(jid, QueryIds.FOLLOW) + }, + + newsletterUnmute: async(jid: string) => { + await newsletterWMexQuery(jid, QueryIds.UNMUTE) + }, + + newsletterMute: async(jid: string) => { + await newsletterWMexQuery(jid, QueryIds.MUTE) + }, + + newsletterCreate: async(name: string, description: string) => { + /**tos query */ + await query({ + tag: 'iq', + attrs: { + to: S_WHATSAPP_NET, + xmlns: 'tos', + id: generateMessageTag(), + type: 'set' + }, + content: [ + { + tag: 'notice', + attrs: { + id: '20601218', + stage: '5' + }, + content: [] + } + ] + }) + const result = await newsletterWMexQuery(undefined, QueryIds.CREATE, { + input: { name, description } + }) + + return extractNewsletterMetadata(result, true) + }, + + newsletterMetadata: async(type: 'invite' | 'jid', key: string, role?: NewsletterViewRole) => { + const result = await newsletterWMexQuery(undefined, QueryIds.METADATA, { + input: { + key, + type: type.toUpperCase(), + 'view_role': role || 'GUEST' + }, + 'fetch_viewer_metadata': true, + 'fetch_full_image': true, + 'fetch_creation_time': true + }) + + return extractNewsletterMetadata(result) + }, + + newsletterAdminCount: async(jid: string) => { + const result = await newsletterWMexQuery(jid, QueryIds.ADMIN_COUNT) + + const buff = getBinaryNodeChild(result, 'result')?.content?.toString() + + return JSON.parse(buff!).data[XWAPaths.ADMIN_COUNT].admin_count as number + }, + + /**user is Lid, not Jid */ + newsletterChangeOwner: async(jid: string, user: string) => { + await newsletterWMexQuery(jid, QueryIds.CHANGE_OWNER, { + 'user_id': user + }) + }, + + /**user is Lid, not Jid */ + newsletterDemote: async(jid: string, user: string) => { + await newsletterWMexQuery(jid, QueryIds.DEMOTE, { + 'user_id': user + }) + }, + + newsletterDelete: async(jid: string) => { + await newsletterWMexQuery(jid, QueryIds.DELETE) + }, + + /**if code wasn't passed, the reaction will be removed (if is reacted) */ + newsletterReactMessage: async(jid: string, serverId: string, code?: string) => { + await query({ + tag: 'message', + attrs: { to: jid, ...(!code ? { edit: '7' } : {}), type: 'reaction', 'server_id': serverId, id: generateMessageID() }, + content: [{ + tag: 'reaction', + attrs: code ? { code } : {} + }] + }) + }, + + newsletterFetchMessages: async(type: 'invite' | 'jid', key: string, count: number, after?: number) => { + const result = await newsletterQuery(S_WHATSAPP_NET, 'get', [ + { + tag: 'messages', + attrs: { type, ...(type === 'invite' ? { key } : { jid: key }), count: count.toString(), after: after?.toString() || '100' } + } + ]) + + return await parseFetchedUpdates(result, 'messages') + }, + + newsletterFetchUpdates: async(jid: string, count: number, after?: number, since?: number) => { + const result = await newsletterQuery(jid, 'get', [ + { + tag: 'message_updates', + attrs: { count: count.toString(), after: after?.toString() || '100', since: since?.toString() || '0' } + } + ]) + + return await parseFetchedUpdates(result, 'updates') + } + } } export const extractNewsletterMetadata = (node: BinaryNode, isCreate?: boolean) => { - const result = getBinaryNodeChild(node, 'result')?.content?.toString() - const metadataPath = JSON.parse(result!).data[isCreate ? XWAPaths.CREATE : XWAPaths.NEWSLETTER] - - const metadata: NewsletterMetadata = { - id: metadataPath.id, - state: metadataPath.state.type, - creation_time: +metadataPath.thread_metadata.creation_time, - name: metadataPath.thread_metadata.name.text, - nameTime: +metadataPath.thread_metadata.name.update_time, - description: metadataPath.thread_metadata.description.text, - descriptionTime: +metadataPath.thread_metadata.description.update_time, - invite: metadataPath.thread_metadata.invite, - handle: metadataPath.thread_metadata.handle, - picture: metadataPath.thread_metadata.picture.direct_path || null, - preview: metadataPath.thread_metadata.preview.direct_path || null, - reaction_codes: metadataPath.thread_metadata?.settings?.reaction_codes?.value, - subscribers: +metadataPath.thread_metadata.subscribers_count, - verification: metadataPath.thread_metadata.verification, - viewer_metadata: metadataPath.viewer_metadata - } - - return metadata + const result = getBinaryNodeChild(node, 'result')?.content?.toString() + const metadataPath = JSON.parse(result!).data[isCreate ? XWAPaths.CREATE : XWAPaths.NEWSLETTER] + + const metadata: NewsletterMetadata = { + id: metadataPath.id, + state: metadataPath.state.type, + 'creation_time': +metadataPath.thread_metadata.creation_time, + name: metadataPath.thread_metadata.name.text, + nameTime: +metadataPath.thread_metadata.name.update_time, + description: metadataPath.thread_metadata.description.text, + descriptionTime: +metadataPath.thread_metadata.description.update_time, + invite: metadataPath.thread_metadata.invite, + handle: metadataPath.thread_metadata.handle, + picture: metadataPath.thread_metadata.picture.direct_path || null, + preview: metadataPath.thread_metadata.preview.direct_path || null, + 'reaction_codes': metadataPath.thread_metadata?.settings?.reaction_codes?.value, + subscribers: +metadataPath.thread_metadata.subscribers_count, + verification: metadataPath.thread_metadata.verification, + 'viewer_metadata': metadataPath.viewer_metadata + } + + return metadata } \ No newline at end of file diff --git a/src/Types/Events.ts b/src/Types/Events.ts index 04ccff39792..7d1df1ba786 100644 --- a/src/Types/Events.ts +++ b/src/Types/Events.ts @@ -8,8 +8,8 @@ import { GroupMetadata, ParticipantAction, RequestJoinAction, RequestJoinMethod import { Label } from './Label' import { LabelAssociation } from './LabelAssociation' import { MessageUpsertType, MessageUserReceiptUpdate, WAMessage, WAMessageKey, WAMessageUpdate } from './Message' +import { NewsletterSettingsUpdate, NewsletterViewRole, SubscriberAction } from './Newsletter' import { ConnectionState } from './State' -import { NewsletterSettingsUpdate, SubscriberAction, NewsletterViewRole } from './Newsletter' export type BaileysEventMap = { /** connection state has been updated -- WS closed, opened, connecting etc. */ diff --git a/src/Types/Newsletter.ts b/src/Types/Newsletter.ts index a786d3966d4..54a7babfbc8 100644 --- a/src/Types/Newsletter.ts +++ b/src/Types/Newsletter.ts @@ -11,39 +11,39 @@ export type NewsletterMute = 'ON' | 'OFF' | 'UNDEFINED' export type NewsletterViewRole = 'ADMIN' | 'GUEST' | 'OWNER' | 'SUBSCRIBER' export type NewsletterViewerMetadata = { - mute: NewsletterMute, + mute: NewsletterMute view_role: NewsletterViewRole } export type NewsletterMetadata = { /**jid of newsletter */ - id: string, + id: string /**state of newsletter */ - state: NewsletterState, + state: NewsletterState /**creation timestamp of newsletter */ - creation_time: number, + creation_time: number /**name of newsletter */ - name: string, + name: string /**timestamp of last name modification of newsletter */ - nameTime: number, + nameTime: number /**description of newsletter */ - description: string, + description: string /**timestamp of last description modification of newsletter */ - descriptionTime: number, + descriptionTime: number /**invite code of newsletter */ - invite: string, + invite: string /**i dont know */ - handle: null, + handle: null /**direct path of picture */ - picture: string | null, + picture: string | null /**direct path of picture preview (lower quality) */ - preview: string | null, + preview: string | null /**reaction mode of newsletter */ - reaction_codes?: NewsletterReactionMode, + reaction_codes?: NewsletterReactionMode /**subscribers count of newsletter */ - subscribers: number, + subscribers: number /**verification state of newsletter */ - verification: NewsletterVerification, + verification: NewsletterVerification /**viewer metadata */ viewer_metadata: NewsletterViewerMetadata } @@ -59,22 +59,22 @@ export type NewsletterReaction = {count: number, code: string} export type NewsletterFetchedUpdate = { /**id of message in newsletter, starts from 100 */ - server_id: string, + server_id: string /**count of views in this message */ - views?: number, + views?: number /**reactions in this message */ - reactions: NewsletterReaction[], + reactions: NewsletterReaction[] /**the message, if you requested only updates, you will not receive message */ message?: proto.IWebMessageInfo } -export enum MexOperations{ +export enum MexOperations { PROMOTE = 'NotificationNewsletterAdminPromote', DEMOTE = 'NotificationNewsletterAdminDemote', UPDATE = 'NotificationNewsletterUpdate' } -export enum XWAPaths{ +export enum XWAPaths { PROMOTE = 'xwa2_notify_newsletter_admin_promote', DEMOTE = 'xwa2_notify_newsletter_admin_demote', ADMIN_COUNT = 'xwa2_newsletter_admin', diff --git a/src/Utils/decode-wa-message.ts b/src/Utils/decode-wa-message.ts index 976503a1342..406cd10fc1c 100644 --- a/src/Utils/decode-wa-message.ts +++ b/src/Utils/decode-wa-message.ts @@ -64,7 +64,7 @@ export function decodeMessageNode( msgType = 'group' author = participant chatId = from - } else if(isJidNewsletter(from)){ + } else if(isJidNewsletter(from)) { msgType = 'newsletter' author = from chatId = from @@ -82,6 +82,10 @@ export function decodeMessageNode( chatId = from author = participant + } else if(isJidNewsletter(from)) { + msgType = 'newsletter' + chatId = from + author = from } else { throw new Boom('Unknown message type', { data: stanza }) } @@ -94,7 +98,7 @@ export function decodeMessageNode( fromMe, id: msgId, participant, - server_id: stanza.attrs?.server_id + 'server_id': stanza.attrs?.server_id } const fullMessage: proto.IWebMessageInfo = { @@ -150,7 +154,7 @@ export const decryptMessageNode = ( let msgBuffer: Uint8Array try { - const e2eType = attrs.type + const e2eType = tag === 'plaintext' ? 'plaintext' : attrs.type switch (e2eType) { case 'skmsg': msgBuffer = await repository.decryptGroupMessage({ @@ -168,16 +172,15 @@ export const decryptMessageNode = ( ciphertext: content }) break - case undefined: + case 'plaintext': msgBuffer = content break default: throw new Error(`Unknown e2e type: ${e2eType}`) } - let msg: proto.IMessage = proto.Message.decode(tag === 'plaintext' ? msgBuffer : unpadRandomMax16(msgBuffer)) - msg = msg?.deviceSentMessage?.message || msg - + let msg: proto.IMessage = proto.Message.decode(e2eType !== 'plaintext' ? unpadRandomMax16(msgBuffer) : msgBuffer) + msg = msg.deviceSentMessage?.message || msg if(msg.senderKeyDistributionMessage) { try { await repository.processSenderKeyDistributionMessage({ @@ -186,7 +189,7 @@ export const decryptMessageNode = ( }) } catch(err) { logger.error({ key: fullMessage.key, err }, 'failed to decrypt message') - } + } } if(fullMessage.message) { diff --git a/src/Utils/messages.ts b/src/Utils/messages.ts index 9a8237acddf..394140689cc 100644 --- a/src/Utils/messages.ts +++ b/src/Utils/messages.ts @@ -126,7 +126,7 @@ export const prepareWAMessageMedia = async( !!uploadData.media.url && !!options.mediaCache && ( // generate the key - mediaType + ':' + uploadData.media.url!.toString() + mediaType + ':' + uploadData.media.url.toString() ) if(mediaType === 'document' && !uploadData.fileName) { @@ -733,7 +733,7 @@ export const extractMessageContent = (content: WAMessageContent | undefined | nu content = normalizeMessageContent(content) if(content?.buttonsMessage) { - return extractFromTemplateMessage(content.buttonsMessage!) + return extractFromTemplateMessage(content.buttonsMessage) } if(content?.templateMessage?.hydratedFourRowTemplate) { diff --git a/src/WABinary/jid-utils.ts b/src/WABinary/jid-utils.ts index 1fdcae0e920..0d0dafca746 100644 --- a/src/WABinary/jid-utils.ts +++ b/src/WABinary/jid-utils.ts @@ -12,7 +12,7 @@ export type JidWithDevice = { } export type FullJid = JidWithDevice & { - server: JidServer | string + server: JidServer domainType?: number } @@ -26,7 +26,7 @@ export const jidDecode = (jid: string | undefined): FullJid | undefined => { return undefined } - const server = jid!.slice(sepIdx + 1) + const server = jid!.slice(sepIdx + 1) as JidServer const userCombined = jid!.slice(0, sepIdx) const [userAgent, device] = userCombined.split(':') From 63e3da7c84f60f9ad019466c956a8a4c392780ec Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 28 Jul 2024 18:41:06 +0300 Subject: [PATCH 3/3] fix(feature/newsletter): final touches (for now) --- src/Socket/messages-recv.ts | 19 +++++----- src/Socket/newsletter.ts | 69 +++++++++++-------------------------- src/Types/Newsletter.ts | 14 ++++++++ 3 files changed, 43 insertions(+), 59 deletions(-) diff --git a/src/Socket/messages-recv.ts b/src/Socket/messages-recv.ts index 3c2143870d2..922f50d2037 100644 --- a/src/Socket/messages-recv.ts +++ b/src/Socket/messages-recv.ts @@ -4,7 +4,7 @@ import { randomBytes } from 'crypto' import NodeCache from 'node-cache' import { proto } from '../../WAProto' import { DEFAULT_CACHE_TTLS, KEY_BUNDLE_TYPE, MIN_PREKEY_COUNT } from '../Defaults' -import { MessageReceiptType, MessageRelayOptions, MessageUserReceipt, MexOperations, NewsletterSettingsUpdate, SocketConfig, WACallEvent, WAMessageKey, WAMessageStatus, WAMessageStubType, WAPatchName, XWAPaths } from '../Types' +import { MessageReceiptType, MessageRelayOptions, MessageUserReceipt, MexOperations, NewsletterSettingsUpdate, SocketConfig, SubscriberAction, WACallEvent, WAMessageKey, WAMessageStatus, WAMessageStubType, WAPatchName, XWAPaths } from '../Types' import { aesDecryptCTR, aesEncryptGCM, @@ -374,25 +374,22 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { let contentPath - if(operation === MexOperations.PROMOTE || operation === MexOperations.DEMOTE) { - let action + if(operation === MexOperations.UPDATE) { + contentPath = content.data[XWAPaths.METADATA_UPDATE] + ev.emit('newsletter-settings.update', { id, update: contentPath.thread_metadata.settings as NewsletterSettingsUpdate }) + } else { + let action: SubscriberAction + if(operation === MexOperations.PROMOTE) { action = 'promote' contentPath = content.data[XWAPaths.PROMOTE] - } - - if(operation === MexOperations.DEMOTE) { + } else { action = 'demote' contentPath = content.data[XWAPaths.DEMOTE] } ev.emit('newsletter-participants.update', { id, author: contentPath.actor.pn, user: contentPath.user.pn, 'new_role': contentPath.user_new_role, action }) } - - if(operation === MexOperations.UPDATE) { - contentPath = content.data[XWAPaths.METADATA_UPDATE] - ev.emit('newsletter-settings.update', { id, update: contentPath.thread_metadata.settings as NewsletterSettingsUpdate }) - } } const processNotification = async(node: BinaryNode) => { diff --git a/src/Socket/newsletter.ts b/src/Socket/newsletter.ts index d3b8683f45a..309efb49c42 100644 --- a/src/Socket/newsletter.ts +++ b/src/Socket/newsletter.ts @@ -1,22 +1,8 @@ -import { NewsletterFetchedUpdate, NewsletterMetadata, NewsletterReaction, NewsletterReactionMode, NewsletterViewRole, SocketConfig, WAMediaUpload, XWAPaths } from '../Types' +import { NewsletterFetchedUpdate, NewsletterMetadata, NewsletterReaction, NewsletterReactionMode, NewsletterViewRole, QueryIds, SocketConfig, WAMediaUpload, XWAPaths } from '../Types' import { decryptMessageNode, generateMessageID, generateProfilePicture } from '../Utils' import { BinaryNode, getAllBinaryNodeChildren, getBinaryNodeChild, getBinaryNodeChildren, S_WHATSAPP_NET } from '../WABinary' import { makeGroupsSocket } from './groups' -enum QueryIds { - JOB_MUTATION = '7150902998257522', - METADATA = '6620195908089573', - UNFOLLOW = '7238632346214362', - FOLLOW = '7871414976211147', - UNMUTE = '7337137176362961', - MUTE = '25151904754424642', - CREATE = '6996806640408138', - ADMIN_COUNT = '7130823597031706', - CHANGE_OWNER = '7341777602580933', - DELETE = '8316537688363079', - DEMOTE = '6551828931592903' -} - export const makeNewsletterSocket = (config: SocketConfig) => { const sock = makeGroupsSocket(config) const { authState, signalRepository, query, generateMessageTag } = sock @@ -49,7 +35,14 @@ export const makeNewsletterSocket = (config: SocketConfig) => { { tag: 'query', attrs: { 'query_id': queryId }, - content: encoder.encode(JSON.stringify({ variables: { 'newsletter_id': jid, ...content } })) + content: encoder.encode( + JSON.stringify({ + variables: { + 'newsletter_id': jid, + ...content + } + }) + ) } ] }) @@ -68,12 +61,17 @@ export const makeNewsletterSocket = (config: SocketConfig) => { return await Promise.all(getAllBinaryNodeChildren(child).map(async messageNode => { messageNode.attrs.from = child?.attrs.jid as string - const views = getBinaryNodeChild(messageNode, 'views_count')?.attrs?.count + const views = parseInt(getBinaryNodeChild(messageNode, 'views_count')?.attrs?.count || '0') const reactionNode = getBinaryNodeChild(messageNode, 'reactions') const reactions = getBinaryNodeChildren(reactionNode, 'reaction') .map(({ attrs }) => ({ count: +attrs.count, code: attrs.code } as NewsletterReaction)) - let data: NewsletterFetchedUpdate + const data: NewsletterFetchedUpdate = { + 'server_id': messageNode.attrs.server_id, + views, + reactions + } + if(type === 'messages') { const { fullMessage: message, decrypt } = await decryptMessageNode( messageNode, @@ -85,24 +83,10 @@ export const makeNewsletterSocket = (config: SocketConfig) => { await decrypt() - data = { - 'server_id': messageNode.attrs.server_id, - views: views ? +views : undefined, - reactions, - message - } - - return data - } else { - data = { - 'server_id': messageNode.attrs.server_id, - views: views ? +views : undefined, - reactions - } - - return data + data.message = message } + return data })) } @@ -146,23 +130,12 @@ export const makeNewsletterSocket = (config: SocketConfig) => { }) }, - newsletterUnfollow: async(jid: string) => { - await newsletterWMexQuery(jid, QueryIds.UNFOLLOW) - }, - - newsletterFollow: async(jid: string) => { - await newsletterWMexQuery(jid, QueryIds.FOLLOW) - }, - - newsletterUnmute: async(jid: string) => { - await newsletterWMexQuery(jid, QueryIds.UNMUTE) - }, - - newsletterMute: async(jid: string) => { - await newsletterWMexQuery(jid, QueryIds.MUTE) + newsletterAction: async(jid: string, type: 'follow' | 'unfollow' | 'mute' | 'unmute') => { + await newsletterWMexQuery(jid, type.toUpperCase() as QueryIds) }, newsletterCreate: async(name: string, description: string) => { + //TODO: Implement TOS system wide for Meta AI, communities, and here etc. /**tos query */ await query({ tag: 'iq', diff --git a/src/Types/Newsletter.ts b/src/Types/Newsletter.ts index 54a7babfbc8..59a261f0e46 100644 --- a/src/Types/Newsletter.ts +++ b/src/Types/Newsletter.ts @@ -81,4 +81,18 @@ export enum XWAPaths { CREATE = 'xwa2_newsletter_create', NEWSLETTER = 'xwa2_newsletter', METADATA_UPDATE = 'xwa2_notify_newsletter_on_metadata_update' +} + +export enum QueryIds { + JOB_MUTATION = '7150902998257522', + METADATA = '6620195908089573', + UNFOLLOW = '7238632346214362', + FOLLOW = '7871414976211147', + UNMUTE = '7337137176362961', + MUTE = '25151904754424642', + CREATE = '6996806640408138', + ADMIN_COUNT = '7130823597031706', + CHANGE_OWNER = '7341777602580933', + DELETE = '8316537688363079', + DEMOTE = '6551828931592903' } \ No newline at end of file