@zervonex/baileys 3.0.8 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  import crypto from 'crypto'
2
2
  import * as Builders from '../Builders/index.js'
3
- import { buildDispatch, buildPlugins, norm, handleGroupStatus, handleStatusMention, handlePollResult, PRIMITIVE_SHORTCUTS, HANDLER_SHORTCUTS, attachAIRich } from '../Builders/index.js'
3
+ import { buildDispatch, buildPlugins, norm, handleGroupStatus, handleStatusMention, handlePollResult, handleNativeFlow, PRIMITIVE_SHORTCUTS, HANDLER_SHORTCUTS, attachAIRich } from '../Builders/index.js'
4
4
  import { makeUsernameSocket } from './username.js'
5
5
 
6
6
  export const makeMessageBuilderSocket = (config) => {
@@ -23,24 +23,19 @@ export const makeMessageBuilderSocket = (config) => {
23
23
 
24
24
  result.sendMessage = async (jid, content, options = {}) => {
25
25
  // aiRich shorthand — multiple aliases supported
26
- const aiData = content.aiRich ?? content.airich ?? content.richResponse ?? content.AIRich
27
- if (aiData) return result.sendRichFromObject(jid, aiData, options)
26
+ const aiData = content.aiRich ?? content.airich ?? content.richResponse ?? content.AIRich ?? content.richMessage
27
+ if (aiData) return result.sendRichFromObject(jid, Array.isArray(aiData) ? { ...content, parts: aiData } : { ...content, ...aiData }, options)
28
28
 
29
29
  // dispatch to registered builder handlers
30
30
  for (const [key, handler] of Object.entries(DISPATCH)) {
31
31
  if (key in content) return handler(result, jid, content, options, config)
32
32
  }
33
-
34
- // bare nativeFlow array/object — wrap into interactiveMessage before sending
35
- if (content.nativeFlow && !content.interactiveMessage) {
36
- const buttons = Array.isArray(content.nativeFlow) ? content.nativeFlow : [content.nativeFlow]
37
- const { generateWAMessageFromContent } = await import('../Utils/index.js')
38
- const msg = await await generateWAMessageFromContent(jid, {
39
- interactiveMessage: { body: { text: content.text || '' }, footer: { text: content.footer || '' }, header: { hasMediaAttachment: false }, nativeFlowMessage: { buttons } }
40
- }, { userJid: sock.user?.id })
41
- await result.relayMessage(jid, msg.message, { messageId: msg.key.id, aiLabel: !!(content.ai ?? content.aiLabel ?? options.aiLabel ?? options.ai ?? config.aiLabel ?? config.ai) })
42
- return msg
43
- }
33
+ // templateButtons check
34
+ if (content.templateButtons) return result.sendMessage(jid, { text: content.text ?? content.caption ?? '', footer: content.footer, ...(content.image ? { image: content.image } : {}), nativeFlow: content.templateButtons.map(b => b.url ? { text: b.text, url: b.url } : b.call ? { text: b.text, call: b.call } : { text: b.text, id: b.id }) }, options)
35
+ // carousel && card message check
36
+ if (content.cards && !content.carouselMessage) return result.sendMessage(jid, { carouselMessage: { caption: content.text ?? content.caption ?? '', footer: content.footer, cards: content.cards } }, options)
37
+ // bare nativeFlow array/object
38
+ if (content.nativeFlow && !content.interactiveMessage) return handleNativeFlow(result, jid, content, options)
44
39
 
45
40
  const { ai, aiLabel: contentAiLabel, ...cleanContent } = content
46
41
  const isAi = !!(ai ?? contentAiLabel ?? options.aiLabel ?? options.ai ?? config.aiLabel)
@@ -1,7 +1,6 @@
1
1
  import * as cacheManager from 'cache-manager';
2
2
  import * as WAProto from '../../WAProto/index.js';
3
- import { BufferJSON, initAuthCreds } from '../Utils/index.js';
4
- import logger from '../Utils/logger.js';
3
+ import { BufferJSON, initAuthCreds, logger } from '../Utils/index.js';
5
4
 
6
5
  const makeCacheManagerAuthState = async (store, sessionKey) => {
7
6
  const defaultKey = (file) => `${sessionKey}:${file}`;
@@ -40,7 +39,7 @@ const makeCacheManagerAuthState = async (store, sessionKey) => {
40
39
  try {
41
40
  const result = await databaseConn.store.keys(`${sessionKey}*`);
42
41
  await Promise.all(result.map(async (key) => await databaseConn.del(key)));
43
- } catch {}
42
+ } catch { }
44
43
  };
45
44
 
46
45
  const creds = (await readData('creds')) || initAuthCreds();
@@ -43,6 +43,7 @@ const makeInMemoryStore = (config) => {
43
43
  const contactsUpsert = (newContacts) => {
44
44
  const oldContacts = new Set(Object.keys(contacts))
45
45
  for (const contact of newContacts) {
46
+ if (!contact || typeof contact.id !== 'string') continue
46
47
  oldContacts.delete(contact.id)
47
48
  contacts[contact.id] = Object.assign(contacts[contact.id] || {}, contact)
48
49
  }
@@ -6,8 +6,9 @@ import PQueue from 'p-queue'
6
6
  import { v4 } from 'uuid'
7
7
  import { DEFAULT_CACHE_TTLS } from '../Defaults/index.js'
8
8
  import { Curve, signedKeyPair } from './crypto.js'
9
- import { delay, generateRegistrationId } from './generics.js'
9
+ import { delay, generateRegistrationId, BufferJSON } from './generics.js'
10
10
  import { PreKeyManager } from './pre-key-manager.js'
11
+ import { proto } from '../../WAProto/index.js'
11
12
 
12
13
  export function makeCacheableSignalKeyStore(store, logger, _cache) {
13
14
  const cache = _cache || new NodeCache({ stdTTL: DEFAULT_CACHE_TTLS.SIGNAL_STORE, useClones: false, deleteOnExpire: true })
@@ -139,4 +140,20 @@ export const initAuthCreds = () => {
139
140
  routingInfo: undefined,
140
141
  additionalData: undefined
141
142
  }
142
- }
143
+ }
144
+
145
+
146
+ export const bootstrapCreds = raw => raw ?? initAuthCreds()
147
+ export const patchAppStateKey = (type, v) => type === 'app-state-sync-key' && v ? proto.Message.AppStateSyncKeyData.fromObject(v) : v
148
+ export const ser = v => v == null ? null : JSON.parse(JSON.stringify(v, BufferJSON.replacer))
149
+ export const deser = raw => {
150
+ if (raw == null) return null
151
+ let p = typeof raw === 'string'
152
+ ? (() => { try { return JSON.parse(raw) } catch { return raw } })()
153
+ : raw
154
+ let depth = 0
155
+ while (depth++ < 3 && p && typeof p === 'object' && !Array.isArray(p) && Object.keys(p).length === 1 && 'value' in p) p = p.value
156
+ if (p == null) return null
157
+ if (typeof p === 'object' && '__checksum' in p && 'data' in p) p = p.data
158
+ return JSON.parse(JSON.stringify(p), BufferJSON.reviver)
159
+ }
@@ -2,6 +2,7 @@ import { proto } from '../../WAProto/index.js'
2
2
  import { BOT_RENDERING_CONFIG_METADATA } from '../Defaults/index.js'
3
3
 
4
4
  export const BotCapabilityType = proto.BotCapabilityMetadata.BotCapabilityType
5
+ export const RichSubMessageType = proto.AIRichResponseSubMessageType
5
6
 
6
7
  const capabilityNames = new Set(Object.keys(BotCapabilityType).filter(n => Number.isNaN(Number(n))))
7
8
 
@@ -28,7 +28,7 @@ export const aesEncryptGCM = (plaintext, key, iv, additionalData) => {
28
28
  }
29
29
 
30
30
  export const aesDecryptGCM = (ciphertext, key, iv, additionalData) => {
31
- const decipher = createDecipheriv('aes-256-gcm', key, iv)
31
+ const decipher = createDecipheriv('aes-256-gcm', key, iv, { authTagLength: GCM_TAG_LENGTH })
32
32
  decipher.setAAD(additionalData)
33
33
  decipher.setAuthTag(ciphertext.subarray(-GCM_TAG_LENGTH))
34
34
  return Buffer.concat([decipher.update(ciphertext.subarray(0, -GCM_TAG_LENGTH)), decipher.final()])
@@ -37,6 +37,7 @@ export const aesDecryptGCM = (ciphertext, key, iv, additionalData) => {
37
37
  export const aesEncryptCTR = (plaintext, key, iv) => { const c = createCipheriv('aes-256-ctr', key, iv); return Buffer.concat([c.update(plaintext), c.final()]) }
38
38
  export const aesDecryptCTR = (ciphertext, key, iv) => { const d = createDecipheriv('aes-256-ctr', key, iv); return Buffer.concat([d.update(ciphertext), d.final()]) }
39
39
 
40
+ // aes-256-cbc is required by the WhatsApp media encryption spec (CBC + HMAC-SHA256)
40
41
  export const aesDecryptWithIV = (buffer, key, IV) => { const d = createDecipheriv('aes-256-cbc', key, IV); return Buffer.concat([d.update(buffer), d.final()]) }
41
42
  export const aesDecrypt = (buffer, key) => aesDecryptWithIV(buffer.subarray(16), key, buffer.subarray(0, 16))
42
43
 
@@ -25,7 +25,7 @@ export const decryptWithRevealKey = (encPayload, encIv, revealKey) => {
25
25
  const key = Buffer.from(revealKey)
26
26
  const payload = Buffer.from(encPayload)
27
27
  if (payload.length < SCHEDULED_MSG_TAG_BYTES) throw new TypeError('encrypted payload is too short to carry an auth tag')
28
- const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(encIv))
28
+ const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(encIv), { authTagLength: SCHEDULED_MSG_TAG_BYTES })
29
29
  decipher.setAuthTag(payload.subarray(payload.length - SCHEDULED_MSG_TAG_BYTES))
30
30
  return Buffer.concat([decipher.update(payload.subarray(0, payload.length - SCHEDULED_MSG_TAG_BYTES)), decipher.final()])
31
31
  }
@@ -44,7 +44,7 @@ export const generateRegistrationId = () => Uint16Array.from(randomBytes(2))[0]
44
44
 
45
45
  export const encodeBigEndian = (e, t = 4) => { let r = e; const a = new Uint8Array(t); for (let i = t - 1; i >= 0; i--) { a[i] = 255 & r; r >>>= 8 }; return a }
46
46
 
47
- export const toNumber = t => typeof t === 'object' && t ? ('toNumber' in t ? t.toNumber() : t.low) : t || 0
47
+ export const toNumber = t => { if (typeof t === 'bigint') return Number(t); if (typeof t === 'object' && t) return 'toNumber' in t ? t.toNumber() : (t.low ?? 0); return t || 0 }
48
48
  export const unixTimestampSeconds = (date = new Date()) => Math.floor(date.getTime() / 1000)
49
49
 
50
50
  export const debouncedTimeout = (intervalMs = 1000, task) => {
@@ -14,6 +14,7 @@ export * from './lt-hash.js'
14
14
  export * from './auth-utils.js'
15
15
  export * from './baileys-event-stream.js'
16
16
  export * from './use-multi-file-auth-state.js'
17
+ export * from './use-keyv-auth-state.js'
17
18
  export * from './link-preview.js'
18
19
  export * from './event-buffer.js'
19
20
  export * from './process-message.js'
@@ -1,4 +1,6 @@
1
1
  import { generateMessageIDV2, tokenizeCode } from './generics.js'
2
+ import { RichSubMessageType } from './bot-capabilities.js'
3
+ import { proto } from '../../WAProto/index.js'
2
4
 
3
5
  const buildRichContextInfo = (quoted, options = {}) => {
4
6
  const ctx = { forwardingScore: 1, isForwarded: true, forwardedAiBotMessageInfo: { botJid: options.botJid || '867051314767696@bot' }, forwardOrigin: 4, ...(options.mentions ? { mentionedJid: options.mentions } : {}) }
@@ -7,14 +9,14 @@ const buildRichContextInfo = (quoted, options = {}) => {
7
9
  }
8
10
 
9
11
  const buildBotForwardedMessage = (submessages, contextInfo, unifiedResponse) => {
10
- const rich = { messageType: 1, submessages, contextInfo }
12
+ const rich = { messageType: proto.AIRichResponseMessageType.AI_RICH_RESPONSE_TYPE_STANDARD, submessages, contextInfo }
11
13
  if (unifiedResponse) rich.unifiedResponse = unifiedResponse
12
14
  return { botForwardedMessage: { message: { richResponseMessage: rich } } }
13
15
  }
14
16
 
15
17
  const wrap = (sub, quoted, options) => ({ message: buildBotForwardedMessage(sub, buildRichContextInfo(quoted, options)), messageId: generateMessageIDV2() })
16
- const text = msg => ({ messageType: 2, messageText: msg })
17
- const table = (title, rows) => ({ messageType: 4, tableMetadata: { title, rows } })
18
+ const text = msg => ({ messageType: RichSubMessageType.AI_RICH_RESPONSE_TEXT, messageText: msg })
19
+ const table = (title, rows) => ({ messageType: RichSubMessageType.AI_RICH_RESPONSE_TABLE, tableMetadata: { title, rows } })
18
20
 
19
21
  export const generateTableContent = (title, headers, rows, quoted, options = {}) => {
20
22
  const { footer, headerText } = options
@@ -30,7 +32,8 @@ export const generateListContent = (title, items, quoted, options = {}) => {
30
32
 
31
33
  export const generateCodeBlockContent = (code, quoted, options = {}) => {
32
34
  const { title, footer, language = 'javascript' } = options
33
- const sub = [...(title ? [text(title)] : []), { messageType: 5, codeMetadata: { codeLanguage: language, codeBlocks: tokenizeCode(code, language) } }, ...(footer ? [text(footer)] : [])]
35
+ const { codeBlocks } = tokenizeCode(code, language)
36
+ const sub = [...(title ? [text(title)] : []), { messageType: RichSubMessageType.AI_RICH_RESPONSE_CODE, codeMetadata: { codeLanguage: language, codeBlocks } }, ...(footer ? [text(footer)] : [])]
34
37
  return wrap(sub, quoted, options)
35
38
  }
36
39
 
@@ -45,7 +48,7 @@ export const generateLatexContent = (quoted, options = {}) => {
45
48
  if (imageTrailingPadding !== undefined) e.imageTrailingPadding = imageTrailingPadding
46
49
  return e
47
50
  })
48
- const sub = [...(headerText ? [text(headerText)] : []), { messageType: 8, latexMetadata: { text: txt || '', expressions: latexExpressions } }, ...(footer ? [text(footer)] : [])]
51
+ const sub = [...(headerText ? [text(headerText)] : []), { messageType: RichSubMessageType.AI_RICH_RESPONSE_LATEX, latexMetadata: { text: txt || '', expressions: latexExpressions } }, ...(footer ? [text(footer)] : [])]
49
52
  return wrap(sub, quoted, options)
50
53
  }
51
54
 
@@ -16,6 +16,7 @@ import { CALL_AUDIO_PREFIX, CALL_VIDEO_PREFIX, MEDIA_KEYS, URL_REGEX, WA_DEFAULT
16
16
  import { WAMessageStatus, WAProto } from '../Types/index.js'
17
17
  import { isJidGroup, isJidNewsletter, isJidStatusBroadcast, jidNormalizedUser } from '../WABinary/index.js'
18
18
  import { sha256 } from './crypto.js'
19
+ import { resolveNativeFlowButtons } from './native-flow.js'
19
20
  import { generateMessageIDV2, getKeyAuthor, unixTimestampSeconds } from './generics.js'
20
21
  import { downloadContentFromMessage, encryptedStream, prepareStream, generateThumbnail, getAudioDuration, getAudioWaveform, getStream, toBuffer, normalizeToBuffer } from './messages-media.js'
21
22
  import { shouldIncludeReportingToken } from './reporting-utils.js'
@@ -546,9 +547,9 @@ const applyContextInfoAndMentions = async (interactive, msg) => {
546
547
 
547
548
  const OVERLAY_HANDLERS = {
548
549
  buttons: async (m, msg) => {
549
- const hasNativeFlow = msg.buttons.some(b => b.nativeFlowInfo || b.name || b.buttonParamsJson)
550
+ const hasNativeFlow = msg.buttons.some(b => b.nativeFlowInfo || b.name || b.buttonParamsJson || b.url || b.call || b.sections)
550
551
  if (hasNativeFlow) {
551
- const interactive = { body: { text: msg.text || msg.caption || msg.contentText || '' }, footer: { text: msg.footer || msg.footerText || '' }, nativeFlowMessage: { buttons: msg.buttons.map(b => b.name && b.buttonParamsJson ? b : b.nativeFlowInfo ? { name: b.nativeFlowInfo.name, buttonParamsJson: b.nativeFlowInfo.paramsJson } : { name: 'quick_reply', buttonParamsJson: JSON.stringify({ display_text: b.buttonText?.displayText || b.displayText || '', id: b.buttonId || b.id || '' }) }) } }
552
+ const interactive = { body: { text: msg.text || msg.caption || msg.contentText || '' }, footer: { text: msg.footer || msg.footerText || '' }, nativeFlowMessage: { buttons: resolveNativeFlowButtons(msg.buttons) } }
552
553
  if (msg.title) interactive.header = { title: msg.title, subtitle: msg.subtitle || '', hasMediaAttachment: msg.hasMediaAttachment || false }
553
554
  if (Object.keys(m).length) { interactive.header ??= { title: msg.title || '', hasMediaAttachment: true }; Object.assign(interactive.header, m) }
554
555
  await applyContextInfoAndMentions(interactive, msg)
@@ -575,6 +576,11 @@ const OVERLAY_HANDLERS = {
575
576
  if (msg.mentions?.length) ht.contextInfo = { ...(ht.contextInfo || {}), mentionedJid: msg.mentions }
576
577
  return { templateMessage: { fourRowTemplate: ht, hydratedTemplate: ht } }
577
578
  },
579
+ audioFooter: async (m, msg, opts) => {
580
+ const audio = await prepareWAMessageMedia({ audio: msg.audioFooter }, { upload: opts.upload ?? opts.waUploadToServer })
581
+ const buttons = resolveNativeFlowButtons(Array.isArray(msg.nativeFlow) ? msg.nativeFlow : [msg.nativeFlow])
582
+ return { interactiveMessage: { body: { text: msg.text || '' }, footer: { text: msg.footer || '' }, header: { hasMediaAttachment: false }, nativeFlowMessage: { buttons }, footerColumns: [{ audioMessage: audio.audioMessage }] } }
583
+ },
578
584
  interactiveButtons: async (m, msg) => {
579
585
  const interactive = { nativeFlowMessage: WAProto.Message.InteractiveMessage.NativeFlowMessage.fromObject({ buttons: msg.interactiveButtons }) }
580
586
  if ('text' in msg) { interactive.body = { text: msg.text }; interactive.header = { title: msg.title || '', subtitle: msg.subtitle || '', hasMediaAttachment: false } }
@@ -628,7 +634,7 @@ export const generateWAMessageContent = async (message, options = {}) => {
628
634
  }
629
635
 
630
636
  // Apply overlays: any overlay key present in message replaces m
631
- for (const key of overlayKeys) { if (key in message && message[key]) { m = await OVERLAY_HANDLERS[key](m, message); break } }
637
+ for (const key of overlayKeys) { if (key in message && message[key]) { m = await OVERLAY_HANDLERS[key](m, message, options); break } }
632
638
 
633
639
  // Merge contextInfo / mentions onto the primary content key — deep-merges externalAdReply to prevent thumbnail/field loss
634
640
  const finalKey = Object.keys(m)[0]
@@ -49,6 +49,32 @@ export const buildWhatsAppFlowButton = ({ flowId, flowToken, cta = 'Open form',
49
49
  }
50
50
  }
51
51
 
52
+ export const resolveNativeFlowButtons = (rawButtons) => {
53
+ const buttons = Array.isArray(rawButtons) ? rawButtons : [rawButtons]
54
+ return buttons.map(b => {
55
+ if (b.name && b.buttonParamsJson) return b // already formatted
56
+ const text = b.text ?? b.display_text ?? ''
57
+ if (b.sections) return { name: 'single_select', buttonParamsJson: JSON.stringify({ title: text, sections: b.sections }) }
58
+ if (b.url && b.useWebview) return { name: 'cta_open_webview', buttonParamsJson: JSON.stringify({ display_text: text, url: b.url }) }
59
+ if (b.url) return { name: 'cta_url', buttonParamsJson: JSON.stringify({ display_text: text, url: b.url, merchant_url: b.merchantUrl ?? b.url }) }
60
+ if (b.call) return { name: 'cta_call', buttonParamsJson: JSON.stringify({ display_text: text, phone_number: b.call }) }
61
+ if (b.copy) return { name: 'cta_copy', buttonParamsJson: JSON.stringify({ display_text: text, copy_code: b.copy }) }
62
+ if (b.flow_id ?? b.flowId) return { name: 'flow', buttonParamsJson: JSON.stringify({ display_text: text, flow_id: b.flow_id ?? b.flowId, flow_token: b.flow_token ?? b.flowToken ?? crypto.randomUUID() }) }
63
+ if (b.remind) return { name: 'cta_reminder', buttonParamsJson: JSON.stringify({ display_text: text, ...b.remind }) }
64
+ if (b.cancelReminder) return { name: 'cta_cancel_reminder', buttonParamsJson: JSON.stringify({ display_text: text }) }
65
+ if (b.location) return { name: 'send_location', buttonParamsJson: JSON.stringify({ display_text: text }) }
66
+ if (b.address) return { name: 'address_message', buttonParamsJson: JSON.stringify({ display_text: text }) }
67
+ if (b.catalog) return { name: 'catalog_message', buttonParamsJson: JSON.stringify({ display_text: text, ...b.catalog }) }
68
+ if (b.reviewAndPay) return { name: 'review_and_pay', buttonParamsJson: JSON.stringify({ display_text: text, ...b.reviewAndPay }) }
69
+ if (b.reviewOrder) return { name: 'review_order', buttonParamsJson: JSON.stringify({ display_text: text, ...b.reviewOrder }) }
70
+ if (b.orderDetails) return { name: 'order_details', buttonParamsJson: JSON.stringify({ display_text: text, ...b.orderDetails }) }
71
+ if (b.paymentStatus) return { name: 'payment_status', buttonParamsJson: JSON.stringify({ display_text: text, ...b.paymentStatus }) }
72
+ if (b.transactionDetails) return { name: 'transaction_details', buttonParamsJson: JSON.stringify({ display_text: text, ...b.transactionDetails }) }
73
+ // default: quick_reply
74
+ return { name: 'quick_reply', buttonParamsJson: JSON.stringify({ display_text: text, id: b.id ?? text }) }
75
+ })
76
+ }
77
+
52
78
  export const makeWhatsAppFlowButton = flow => ({
53
79
  name: flow?.nativeFlowName || 'galaxy_message',
54
80
  buttonParamsJson: JSON.stringify(buildWhatsAppFlowButton(flow)),
@@ -1,14 +1,31 @@
1
- import { getBinaryNodeChild, getBinaryNodeChildString, isLidUser, isHostedLidUser, S_WHATSAPP_NET } from '../WABinary/index.js'
1
+ import { getBinaryNodeChild, getBinaryNodeChildString, isLidUser, isHostedLidUser, isJidGroup, S_WHATSAPP_NET } from '../WABinary/index.js'
2
2
  import { extractMessageContent } from './messages.js'
3
3
 
4
+ // ─── Constants ────────────────────────────────────────────────────────────────
5
+
6
+ // Controls how WA routes the report internally — pass via options.spamFlow to override auto-selection.
7
+ // ACCOUNT_INFO_REPORT → tag-based 1:1 contact report (default)
8
+ // ACCOUNT_INFO_BLOCK → tag-based contact report signalling intent to block
9
+ // GROUP_INFO_REPORT → tag-based group report (auto-selected for group JIDs)
10
+ // CONTACT_INFO_REPORT → legacy lightweight report, no reporting_tag needed
11
+ // ONE_TO_ONE_SPAM_BANNER_REPORT → from the in-chat spam banner
12
+ // MESSAGE_MENU → from long-press message context menu
13
+ // OVERFLOW_MENU_REPORT → from the chat overflow (⋮) menu
4
14
  export const SPAM_FLOWS = {
5
15
  ACCOUNT_INFO_REPORT: 'account_info_report',
6
16
  ACCOUNT_INFO_BLOCK: 'account_info_block',
17
+ GROUP_INFO_REPORT: 'group_info_report',
18
+ CONTACT_INFO_REPORT: 'contact_info_report',
7
19
  ONE_TO_ONE_SPAM_BANNER_REPORT: '1_1_spam_banner_report',
8
20
  MESSAGE_MENU: 'message_menu',
9
21
  OVERFLOW_MENU_REPORT: 'overflow_menu_report',
10
22
  }
11
23
 
24
+ // Numeric reason codes for the w:report IQ endpoint — used by reportUser() and reportMessage().
25
+ export const REPORT_REASON_CODES = { spam: 1, harassment: 2, impersonation: 3, scam: 4, other: 5 }
26
+
27
+ // ─── Internal helpers ─────────────────────────────────────────────────────────
28
+
12
29
  const toBuffer = data => {
13
30
  if (!data) return null
14
31
  if (Buffer.isBuffer(data)) return data
@@ -17,32 +34,44 @@ const toBuffer = data => {
17
34
  return null
18
35
  }
19
36
 
37
+ // ─── JID resolution ───────────────────────────────────────────────────────────
38
+
39
+ // Resolves a PN JID to its LID equivalent for tag-based spam IQs.
40
+ // Groups and already-LID/hosted-LID JIDs pass through unchanged.
20
41
  export const resolveReportJid = async (jid, lidMapping) => {
21
- if (!jid) throw new Error('reportSpam: jid required')
22
- if (isLidUser(jid) || isHostedLidUser(jid)) return jid
23
- if (lidMapping?.getLIDForPN) {
24
- const lid = await lidMapping.getLIDForPN(jid)
25
- if (lid) return lid
26
- }
27
- return jid
42
+ if (!jid) throw new Error('resolveReportJid: jid required')
43
+ if (isJidGroup(jid) || isLidUser(jid) || isHostedLidUser(jid)) return jid
44
+ const lid = lidMapping?.getLIDForPN ? await lidMapping.getLIDForPN(jid) : null
45
+ return lid ?? jid
46
+ }
47
+
48
+ // Auto-detects JID type and returns the resolved JID + correct spam_flow for reportSpam().
49
+ // Returns { reportJid, spamFlow, isGroup }.
50
+ export const resolveReportContext = async (jid, lidMapping) => {
51
+ if (!jid) throw new Error('resolveReportContext: jid required')
52
+ if (isJidGroup(jid)) return { reportJid: jid, spamFlow: SPAM_FLOWS.GROUP_INFO_REPORT, isGroup: true }
53
+ return { reportJid: await resolveReportJid(jid, lidMapping), spamFlow: SPAM_FLOWS.ACCOUNT_INFO_REPORT, isGroup: false }
28
54
  }
29
55
 
56
+ // ─── Tag / text extraction ────────────────────────────────────────────────────
57
+
58
+ // Normalizes a raw reporting_tag to a Buffer — returns null if invalid or empty.
30
59
  export const normalizeReportingTag = tag => {
31
60
  const buf = toBuffer(tag)
32
61
  return buf?.length ? buf : null
33
62
  }
34
63
 
64
+ // Extracts plain text from a WAMessage across common message types.
35
65
  export const extractMessageText = msg => {
36
66
  if (!msg?.message) return ''
37
- const content = extractMessageContent(msg.message)
38
- if (!content) return ''
39
- if (typeof content.conversation === 'string') return content.conversation
40
- if (content.extendedTextMessage?.text) return content.extendedTextMessage.text
41
- if (content.imageMessage?.caption) return content.imageMessage.caption
42
- if (content.videoMessage?.caption) return content.videoMessage.caption
43
- return ''
67
+ const c = extractMessageContent(msg.message)
68
+ if (!c) return ''
69
+ return c.conversation ?? c.extendedTextMessage?.text ?? c.imageMessage?.caption ?? c.videoMessage?.caption ?? ''
44
70
  }
45
71
 
72
+ // Extracts { stanzaId, timestamp, reportingTag, reportingToken, tokenVersion, validationPolicy }
73
+ // from a raw incoming binary stanza. Call inside messages.upsert and persist via reportingInfoStore
74
+ // so reportSpam() can use the tags later.
46
75
  export const extractReportingInfoFromStanza = stanza => {
47
76
  const reporting = getBinaryNodeChild(stanza, 'reporting')
48
77
  if (!reporting) return null
@@ -56,60 +85,71 @@ export const extractReportingInfoFromStanza = stanza => {
56
85
  const stanzaId = tagNode.attrs?.id || stanza.attrs?.id
57
86
  if (!stanzaId) return null
58
87
  const tsRaw = tagNode.attrs?.ts_s || stanza.attrs?.t
59
- const reportingToken = tokenNode?.content ? toBuffer(tokenNode.content) : null
60
- const tokenVersion = tokenNode?.attrs?.v ? parseInt(tokenNode.attrs.v) : null
61
- const validationPolicy = reporting.attrs?.validation_policy || null
62
- return { stanzaId, timestamp: tsRaw ? Number(tsRaw) : 0, reportingTag, reportingToken, tokenVersion, validationPolicy }
88
+ return {
89
+ stanzaId,
90
+ timestamp: tsRaw ? Number(tsRaw) : 0,
91
+ reportingTag,
92
+ reportingToken: tokenNode?.content ? toBuffer(tokenNode.content) : null,
93
+ tokenVersion: tokenNode?.attrs?.v ? parseInt(tokenNode.attrs.v) : null,
94
+ validationPolicy: reporting.attrs?.validation_policy || null,
95
+ }
63
96
  }
64
97
 
65
- export const buildSpamReportMessageNodes = messages => messages.slice(0, 5).map(m => {
66
- const reportingTag = normalizeReportingTag(m.reportingTag)
67
- if (!reportingTag) throw new Error(`reportSpam: missing reportingTag for ${m.stanzaId}`)
68
- return {
69
- tag: 'message',
70
- attrs: {
71
- t: String(m.timestamp),
72
- id: m.stanzaId,
73
- reported_push_name: m.pushName || '',
74
- type: m.messageType || 'text',
75
- from: m.fromJid,
76
- },
77
- content: [
78
- {
79
- tag: 'raw',
80
- attrs: { local_message_type: '0', v: '2' },
81
- content: m.text ?? '',
82
- },
83
- {
84
- tag: 'reporting',
85
- attrs: {},
86
- content: [{
87
- tag: 'reporting_validation',
88
- attrs: {},
89
- content: [{
90
- tag: 'reporting_tag',
91
- attrs: { id: m.stanzaId, ts_s: String(m.timestamp) },
92
- content: reportingTag,
93
- }],
94
- }],
95
- },
96
- ],
98
+ // ─── Node builders ────────────────────────────────────────────────────────────
99
+
100
+ // Builds <message> child nodes for a spam_list IQ.
101
+ // Skips messages missing a reportingTag (warn) — throws only if all are filtered (error).
102
+ export const buildSpamReportMessageNodes = (messages, logger) => {
103
+ const ts = () => Math.floor(Date.now() / 1000)
104
+ const nodes = messages.slice(0, 5).reduce((acc, m) => {
105
+ const reportingTag = normalizeReportingTag(m.reportingTag)
106
+ if (!reportingTag) {
107
+ logger?.warn?.({ stanzaId: m.stanzaId }, 'buildSpamReportMessageNodes: skipping — missing reportingTag')
108
+ return acc
109
+ }
110
+ const t = String(m.timestamp || ts())
111
+ acc.push({
112
+ tag: 'message',
113
+ attrs: { t, id: m.stanzaId, reported_push_name: m.pushName || '', type: m.messageType || 'text', from: m.fromJid },
114
+ content: [
115
+ { tag: 'raw', attrs: { local_message_type: '0', v: '2' }, content: m.text != null ? String(m.text) : '' },
116
+ { tag: 'reporting', attrs: {}, content: [{ tag: 'reporting_validation', attrs: {}, content: [{ tag: 'reporting_tag', attrs: { id: m.stanzaId, ts_s: t }, content: reportingTag }] }] },
117
+ ],
118
+ })
119
+ return acc
120
+ }, [])
121
+ if (!nodes.length) {
122
+ logger?.error?.({}, 'buildSpamReportMessageNodes: all messages filtered — none had a valid reportingTag')
123
+ throw new Error('reportSpam: no valid messages with reportingTag after filtering')
97
124
  }
98
- })
125
+ return nodes
126
+ }
99
127
 
100
- export const buildSpamReportIq = (jid, messages, spamFlow = SPAM_FLOWS.ACCOUNT_INFO_REPORT) => {
101
- if (!messages?.length) throw new Error('reportSpam: at least one message with reporting_tag required')
128
+ // Builds the full xmlns="spam" IQ stanza for tag-based reporting — used by reportSpam().
129
+ export const buildSpamReportIq = (jid, messages, spamFlow = SPAM_FLOWS.ACCOUNT_INFO_REPORT, logger) => {
130
+ if (!messages?.length) {
131
+ logger?.error?.({ jid, spamFlow }, 'buildSpamReportIq: called with empty messages array')
132
+ throw new Error('reportSpam: at least one message with reporting_tag required')
133
+ }
102
134
  return {
103
135
  tag: 'iq',
104
136
  attrs: { type: 'set', xmlns: 'spam', to: S_WHATSAPP_NET },
105
- content: [{
106
- tag: 'spam_list',
107
- attrs: { jid, spam_flow: spamFlow },
108
- content: buildSpamReportMessageNodes(messages),
109
- }],
137
+ content: [{ tag: 'spam_list', attrs: { jid, spam_flow: spamFlow }, content: buildSpamReportMessageNodes(messages, logger) }],
110
138
  }
111
139
  }
112
140
 
141
+ // Builds the simpler xmlns="spam" <report> node — used by reportContact() and reportGroup().
142
+ // Does NOT require reporting_tags, only message key ids or bare id strings.
143
+ export const buildSimpleReportNode = (jid, messageKeys) => ({
144
+ tag: 'report',
145
+ attrs: { jid, type: 'spam' },
146
+ content: (messageKeys || []).map(key => ({ tag: 'message', attrs: { id: key.id || key, ...(key.fromMe ? {} : { from: jid }) } })),
147
+ })
148
+
149
+ // ─── Response parser ──────────────────────────────────────────────────────────
150
+
151
+ // Parses the IQ result node from any spam report query.
152
+ // Returns { success: true, reportId? } on success or { success: false, error } on failure.
113
153
  export const parseSpamReportResponse = result => {
114
154
  if (!result) return { success: false, error: 'empty response' }
115
155
  if (result.attrs?.type === 'error') {
@@ -123,13 +163,4 @@ export const parseSpamReportResponse = result => {
123
163
  if (reportId) return { success: true, reportId: String(reportId) }
124
164
  if (result.attrs?.type === 'result') return { success: true }
125
165
  return { success: false, error: 'unexpected spam IQ response' }
126
- }
127
-
128
- export const buildSimpleReportNode = (jid, messageKeys) => ({
129
- tag: 'report',
130
- attrs: { jid, type: 'spam' },
131
- content: (messageKeys || []).map(key => ({
132
- tag: 'message',
133
- attrs: { id: key.id || key, from: key.fromMe ? undefined : jid },
134
- })),
135
- })
166
+ }