From c0b8caa717cda32a37e7163a073cb0c06c329b7f Mon Sep 17 00:00:00 2001 From: Javier Cuevas Date: Thu, 18 Jan 2024 20:45:36 -0300 Subject: [PATCH 1/3] Basic support for channels --- src/Utils/decode-wa-message.ts | 24 +++++++++++++++++++++--- src/WABinary/jid-utils.ts | 2 ++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/Utils/decode-wa-message.ts b/src/Utils/decode-wa-message.ts index 096cdc9a..046290f3 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 { 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. @@ -78,6 +78,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 }) } @@ -132,7 +136,21 @@ export const decryptMessageNode = ( fullMessage.verifiedBizName = details.verifiedName } - if(tag !== 'enc') { + if(tag === 'plaintext') { + // plaintext messages are used for newsletter (whatsapp channel) messages + if(content instanceof Uint8Array) { + try { + fullMessage.message = proto.Message.decode(content) + } catch(err) { + logger.error( + { key: fullMessage.key, err }, + 'failed to decode plaintext message' + ) + fullMessage.messageStubType = proto.WebMessageInfo.StubType.CIPHERTEXT + fullMessage.messageStubParameters = [err.message, 'plaintext'] + } + } + } else if(tag !== 'enc') { continue } diff --git a/src/WABinary/jid-utils.ts b/src/WABinary/jid-utils.ts index cb58eefd..1db7486b 100644 --- a/src/WABinary/jid-utils.ts +++ b/src/WABinary/jid-utils.ts @@ -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 (Whatsapp Channel) */ +export const isJidNewsletter = (jid: string | undefined) => (jid?.endsWith('@newsletter')) /** is the jid the status broadcast */ export const isJidStatusBroadcast = (jid: string) => jid === 'status@broadcast' From 83ad245479a4a144cc8e05e1793f726375102960 Mon Sep 17 00:00:00 2001 From: Javier Cuevas Date: Mon, 22 Jan 2024 14:44:42 -0300 Subject: [PATCH 2/3] Added helpers for transforming snakeCase to camelCase --- src/WABinary/generic-utils.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/WABinary/generic-utils.ts b/src/WABinary/generic-utils.ts index 48b323c5..06dd10e4 100644 --- a/src/WABinary/generic-utils.ts +++ b/src/WABinary/generic-utils.ts @@ -118,4 +118,24 @@ export function binaryNodeToString(node: BinaryNode | BinaryNode['content'], i = const content: string = children ? `>\n${children}\n${tabs(i)}` : '/>' return tag + content +} + +function toCamelCase(str) { + return str.replace(/([-_][a-z])/g, (group) => group.toUpperCase().replace('-', '').replace('_', '')) +} + +export function convertKeysToCamelCase(obj) { + if(typeof obj !== 'object' || obj === null) { + return obj + } + + if(Array.isArray(obj)) { + return obj.map((v) => convertKeysToCamelCase(v)) + } + + return Object.keys(obj).reduce((result, key) => { + const camelCaseKey = toCamelCase(key) + result[camelCaseKey] = convertKeysToCamelCase(obj[key]) + return result + }, {}) } \ No newline at end of file From 49544bf024858cb63e040492676d4a7e10e13631 Mon Sep 17 00:00:00 2001 From: Javier Cuevas Date: Mon, 22 Jan 2024 14:47:16 -0300 Subject: [PATCH 3/3] Added support for fetching subscribed newsletters (Whatsapp Channels). --- src/Socket/index.ts | 2 +- src/Socket/newsletters.ts | 60 ++++++++++++++++++++++++++++++ src/Types/Newsletter.ts | 78 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 139 insertions(+), 1 deletion(-) create mode 100644 src/Socket/newsletters.ts create mode 100644 src/Types/Newsletter.ts diff --git a/src/Socket/index.ts b/src/Socket/index.ts index 7338c9ce..9a1d005f 100644 --- a/src/Socket/index.ts +++ b/src/Socket/index.ts @@ -1,6 +1,6 @@ import { DEFAULT_CONNECTION_CONFIG } from '../Defaults' import { UserFacingSocketConfig } from '../Types' -import { makeRegistrationSocket as _makeSocket } from './registration' +import { makeNewslettersSocket as _makeSocket } from './newsletters' // export the last socket layer const makeWASocket = (config: UserFacingSocketConfig) => ( diff --git a/src/Socket/newsletters.ts b/src/Socket/newsletters.ts new file mode 100644 index 00000000..c2fa409f --- /dev/null +++ b/src/Socket/newsletters.ts @@ -0,0 +1,60 @@ +import { SocketConfig } from '../Types' +import { GraphQLResponse, NewsletterMetadata } from '../Types/Newsletter' +import { convertKeysToCamelCase, getBinaryNodeChildBuffer, S_WHATSAPP_NET } from '..' +import { makeRegistrationSocket } from './registration' + +export function makeNewslettersSocket(config: SocketConfig) { + const sock = makeRegistrationSocket(config) + const { query } = sock + + const mexQuery = async(queryId: string, variables = {}) => { + const response = await query({ + tag: 'iq', + attrs: { + type: 'get', + xmlns: 'w:mex', + to: S_WHATSAPP_NET + }, + content: [ { tag: 'query', attrs: { 'query_id': queryId }, content: JSON.stringify({ variables }) } ] + }) + + const result = getBinaryNodeChildBuffer(response, 'result') + + if(!result) { + throw new Error('Missing "result" element in response') + } + + const gqlResp: GraphQLResponse = JSON.parse(result.toString()) + + if(gqlResp.errors?.length > 0) { + throw new Error('Graphql Error when fetching newsletters') + } + + return gqlResp.data + } + + // const queryFetchNewsletter = '6563316087068696' + // const queryFetchNewsletterDehydrated = '7272540469429201' + // const queryRecommendedNewsletters = '7263823273662354' // variables -> input -> {limit: 20, country_codes: [string]}, output: xwa2_newsletters_recommended + // const queryNewslettersDirectory = '6190824427689257' // variables -> input -> {view: "RECOMMENDED", limit: 50, start_cursor: base64, filters: {country_codes: [string]}} + const querySubscribedNewsletters = '6388546374527196' // variables -> empty, output: xwa2_newsletter_subscribed + // const queryNewsletterSubscribers = '9800646650009898' // variables -> input -> {newsletter_id, count}, output: xwa2_newsletter_subscribers -> subscribers -> edges + // const mutationMuteNewsletter = '6274038279359549' // variables -> {newsletter_id, updates->{description, settings}}, output: xwa2_newsletter_update -> NewsletterMetadata without viewer meta + // const mutationUnmuteNewsletter = '6068417879924485' + // const mutationUpdateNewsletter = '7150902998257522' + // const mutationCreateNewsletter = '6234210096708695' + // const mutationUnfollowNewsletter = '6392786840836363' + // const mutationFollowNewsletter = '9926858900719341' + + return { + ...sock, + getSubscribedNewsletters: async() => { + const result = await mexQuery(querySubscribedNewsletters, {}) + if(result?.['xwa2_newsletter_subscribed']) { + return convertKeysToCamelCase(result['xwa2_newsletter_subscribed']) as NewsletterMetadata[] + } else { + throw new Error('Missing "xwa2_newsletter_subscribed" in response') + } + }, + } +} diff --git a/src/Types/Newsletter.ts b/src/Types/Newsletter.ts new file mode 100644 index 00000000..0f2e1ce3 --- /dev/null +++ b/src/Types/Newsletter.ts @@ -0,0 +1,78 @@ +export interface NewsletterMuted { + muted: boolean +} + +interface WrappedNewsletterState { + type: string +} + +export interface NewsletterMetadata { + id: string + state: WrappedNewsletterState + threadMetadata: NewsletterThreadMetadata + viewerMetadata: NewsletterViewerMetadata | null +} + +interface NewsletterViewerMetadata { + mute: string + role: string +} + +interface NewsletterReactionSettings { + value: string +} + +interface NewsletterSettings { + reactionCodes: NewsletterReactionSettings +} + +interface ProfilePictureInfo { + url: string + id: string + type: string + directPath: string +} + +interface NewsletterThreadMetadata { + creationTime: string + invite: string + name: NewsletterText + description: NewsletterText + subscribersCount: number + verification: string + picture: ProfilePictureInfo | null + preview: ProfilePictureInfo + settings: NewsletterSettings +} + +interface NewsletterText { + text: string + id: string + updateTime: string +} + +export interface NewsletterMessage { + messageServerId: string + viewsCount: number + reactionCounts: { [key: string]: number } + message: any // Replace with actual type +} + +interface GraphQLErrorExtensions { + errorCode: number + isRetryable: boolean + severity: string +} + +interface GraphQLError { + extensions: GraphQLErrorExtensions + message: string + path: string[] +} + +interface GraphQLErrors extends Array {} + +export interface GraphQLResponse { + data: any // Replace with actual type + errors: GraphQLErrors +} \ No newline at end of file