[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:
committed by
GitHub
parent
5ffb19120d
commit
8391c02e0b
@@ -359,3 +359,5 @@ export const extractGroupMetadata = (result: BinaryNode) => {
|
||||
}
|
||||
return metadata
|
||||
}
|
||||
|
||||
export type GroupsSocket = ReturnType<typeof makeGroupsSocket>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user