diff --git a/README.md b/README.md index ea2ec9d..bf61551 100644 --- a/README.md +++ b/README.md @@ -3721,6 +3721,34 @@ await sock.updateMemberLabel( ) ``` +## 🧩 Raw Message (`raw`) +Use raw mode when you want to send a message built directly from WhatsApp proto message keys. + +> [!IMPORTANT] +> - Raw mode is **explicit only**: set `raw: true`. +> - Do **not** mix helper fields (`text`, `image`, `buttons`, `interactiveButtons`, etc.) with raw mode. +> - Top-level keys must be valid `proto.Message` keys (for example `extendedTextMessage`, `imageMessage`, `videoMessage`). + +```ts +await sock.sendMessage(jid, { + extendedTextMessage: { + text: '📃 Built manually from scratch using the raw WhatsApp proto structure', + contextInfo: { + externalAdReply: { + title: '@itsliaaa/baileys', + thumbnail: fs.readFileSync('./path/to/image.jpg'), + sourceApp: 'whatsapp', + showAdAttribution: true, + mediaType: 1 + } + } + }, + raw: true +}, { + quoted: message +}) +``` + ## 👥 Group Status (`groupStatus`) Post a message as a **group status update**. When `groupStatus: true` is set, the library: diff --git a/assets/examples/README.md b/assets/examples/README.md index 6a9e2fd..ac550d8 100644 --- a/assets/examples/README.md +++ b/assets/examples/README.md @@ -3863,6 +3863,34 @@ sock.ws.on('CB:edge_routing,id:abcd,routing_info', (node: BinaryNode) => { }) ## Additional Message Utilities +### Raw Message (`raw`) +Use raw mode when you want to send a message built directly from WhatsApp proto message keys. + +> [!IMPORTANT] +> - Raw mode is **explicit only**: set `raw: true`. +> - Do **not** mix helper fields (`text`, `image`, `buttons`, `interactiveButtons`, etc.) with raw mode. +> - Top-level keys must be valid `proto.Message` keys (for example `extendedTextMessage`, `imageMessage`, `videoMessage`). + +```ts +await sock.sendMessage(jid, { + extendedTextMessage: { + text: '📃 Built manually from scratch using the raw WhatsApp proto structure', + contextInfo: { + externalAdReply: { + title: '@itsliaaa/baileys', + thumbnail: fs.readFileSync('./path/to/image.jpg'), + sourceApp: 'whatsapp', + showAdAttribution: true, + mediaType: 1 + } + } + }, + raw: true +}, { + quoted: message +}) +``` + ### Group Status (`groupStatus`) Post a message as a **group status update**. When `groupStatus: true` is set, the library: diff --git a/lib/Types/Message.d.ts b/lib/Types/Message.d.ts index 17b3eb8..d6a15a4 100644 --- a/lib/Types/Message.d.ts +++ b/lib/Types/Message.d.ts @@ -401,7 +401,11 @@ export type RichMessageHelpers = { table?: string[][] } -export type AnyMessageContent = AnyRegularMessageContent | { +export type RawMessageContent = WAProto.IMessage & { + raw: true +} + +export type AnyMessageContent = AnyRegularMessageContent | RawMessageContent | { forward: WAMessage force?: boolean } | { diff --git a/lib/Utils/messages.js b/lib/Utils/messages.js index 8e8bd20..c8783a7 100644 --- a/lib/Utils/messages.js +++ b/lib/Utils/messages.js @@ -37,6 +37,49 @@ const MessageTypeProto = { 'document': Types_1.WAProto.Message.DocumentMessage, } +const MESSAGE_PROTO_KEYS = new Set( + Object.keys(WAProto_1.proto.Message.prototype).filter( + key => !key.startsWith('_') && typeof WAProto_1.proto.Message.prototype[key] !== 'function' + ) +) + +const RAW_MODE_HELPER_KEYS = new Set([ + 'raw', 'text', 'linkPreview', 'mentions', 'mentionAll', 'contextInfo', 'buttons', 'templateButtons', + 'interactiveButtons', 'nativeFlow', 'optionText', 'optionTitle', 'offerText', 'offerCode', 'offerUrl', + 'offerExpiration', 'title', 'subtitle', 'footer', 'hasMediaAttachment', 'sections', 'buttonText', 'listType', + 'cards', 'contacts', 'location', 'react', 'delete', 'forward', 'force', 'disappearingMessagesInChat', + 'groupInvite', 'adminInvite', 'pin', 'keep', 'call', 'event', 'payment', 'paymentInvite', 'poll', 'pollResult', + 'order', 'product', 'businessOwnerJid', 'stickerPack', 'sharePhoneNumber', 'requestPhoneNumber', 'album', + 'interactiveAsTemplate', 'groupStatus', 'ephemeral', 'edit', 'viewOnce', 'viewOnceV2', 'viewOnceV2Extension', + 'viewOnceExt', 'spoiler', 'isLottie', 'secureMetaServiceLabel', 'code', 'links', 'table', 'richResponse', + 'productList', 'thumbnail', 'audioFooter', 'caption', 'shop', 'shopSurface', 'collection', 'bizJid', 'id', + 'ptv', 'image', 'video', 'audio', 'sticker', 'document', 'mimetype', 'jpegThumbnail', 'gifPlayback', 'fileName', + 'seconds' +]) + +const buildRawMessageContent = (message) => { + const rawPayload = { ...message } + delete rawPayload.raw + + const helperKeys = Object.keys(rawPayload).filter( + key => RAW_MODE_HELPER_KEYS.has(key) && !MESSAGE_PROTO_KEYS.has(key) + ) + if (helperKeys.length) { + throw new boom_1.Boom(`Raw mode does not support helper fields: ${helperKeys.join(', ')}`, { statusCode: 400 }) + } + + const invalidKeys = Object.keys(rawPayload).filter(key => !MESSAGE_PROTO_KEYS.has(key)) + if (invalidKeys.length) { + throw new boom_1.Boom(`Raw mode payload contains unsupported top-level keys: ${invalidKeys.join(', ')}`, { statusCode: 400 }) + } + + if (!Object.keys(rawPayload).length) { + throw new boom_1.Boom('Raw mode payload must include at least one proto message key', { statusCode: 400 }) + } + + return Types_1.WAProto.Message.fromObject(rawPayload) +} + /** * Uses a regex to test whether the string contains a URL, and returns the URL if it does. * @param text eg. hello https://google.com @@ -518,6 +561,9 @@ const buildMentionContextInfo = (message) => { } const generateWAMessageContent = async (message, options) => { + if ('raw' in message && message.raw === true) { + return buildRawMessageContent(message) + } let m = {} if ('text' in message) { diff --git a/test/messages.test.js b/test/messages.test.js index 444f351..516f230 100644 --- a/test/messages.test.js +++ b/test/messages.test.js @@ -1,4 +1,13 @@ -const { generateWAMessageContent } = require('../lib/Utils/messages') +const { generateWAMessageContent, generateWAMessage } = require('../lib/Utils/messages') + +const testLogger = { + trace: jest.fn(), + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + child: jest.fn(() => testLogger) +} describe('generateWAMessageContent Buttons Parsing', () => { test('should parse mixed native flow and response buttons correctly', async () => { @@ -28,15 +37,7 @@ describe('generateWAMessageContent Buttons Parsing', () => { ] } - const options = { - logger: { - trace: jest.fn(), - debug: jest.fn(), - info: jest.fn(), - warn: jest.fn(), - error: jest.fn() - } - } + const options = { logger: testLogger } const result = await generateWAMessageContent(message, options) @@ -65,6 +66,116 @@ describe('generateWAMessageContent Buttons Parsing', () => { }) }) +describe('generateWAMessageContent Raw Mode', () => { + test('should serialize raw extendedTextMessage payload', async () => { + const message = { + raw: true, + extendedTextMessage: { + text: 'Raw payload', + contextInfo: { + externalAdReply: { + title: '@itsliaaa/baileys', + sourceApp: 'whatsapp', + showAdAttribution: true, + mediaType: 1 + } + } + } + } + + const result = await generateWAMessageContent(message, { logger: testLogger }) + + expect(result.extendedTextMessage).toBeDefined() + expect(result.extendedTextMessage.text).toBe('Raw payload') + expect(result.extendedTextMessage.contextInfo.externalAdReply.title).toBe('@itsliaaa/baileys') + }) + + test('should preserve raw contextInfo and still attach quoted metadata', async () => { + const jid = '1234567890@s.whatsapp.net' + const quoted = { + key: { + remoteJid: jid, + fromMe: false, + id: 'quoted-message-id', + participant: '1111111111@s.whatsapp.net' + }, + message: { + conversation: 'quoted text' + } + } + + const result = await generateWAMessage(jid, { + raw: true, + extendedTextMessage: { + text: 'Raw with quote', + contextInfo: { + externalAdReply: { + title: '@itsliaaa/baileys', + sourceApp: 'whatsapp', + showAdAttribution: true, + mediaType: 1 + } + } + } + }, { + userJid: '9999999999@s.whatsapp.net', + quoted, + logger: testLogger + }) + + const contextInfo = result.message.extendedTextMessage.contextInfo + expect(contextInfo.externalAdReply.title).toBe('@itsliaaa/baileys') + expect(contextInfo.stanzaId).toBe('quoted-message-id') + expect(contextInfo.participant).toBe('1111111111@s.whatsapp.net') + expect(contextInfo.quotedMessage).toBeDefined() + }) + + test('should reject mixed helper and raw payload fields', async () => { + await expect(generateWAMessageContent({ + raw: true, + text: 'hello', + extendedTextMessage: { + text: 'Raw payload' + } + }, { logger: testLogger })) + .rejects + .toThrow('Raw mode does not support helper fields: text') + }) + + test('should reject invalid raw top-level keys', async () => { + await expect(generateWAMessageContent({ + raw: true, + notAProtoField: { + value: 'x' + } + }, { logger: testLogger })) + .rejects + .toThrow('Raw mode payload contains unsupported top-level keys: notAProtoField') + }) + + test('should reject raw payload keys inherited from proto functions', async () => { + await expect(generateWAMessageContent({ + raw: true, + toJSON: { + value: 'x' + } + }, { logger: testLogger })) + .rejects + .toThrow('Raw mode payload contains unsupported top-level keys: toJSON') + }) + + test('should allow raw proto keys that overlap helper names', async () => { + const result = await generateWAMessageContent({ + raw: true, + call: { + callKey: 'abc' + } + }, { logger: testLogger }) + + expect(result.call).toBeDefined() + }) +}) + describe('Interactive Carousel Biz Node', () => { const { shouldIncludeBizBinaryNode } = require('../lib/Utils/messages') const { getBizBinaryNode } = require('../lib/WABinary/generic-utils')