@violetix/baileys 0.3.18 โ†’ 0.5.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.
package/README.md CHANGED
@@ -381,6 +381,14 @@ and the sections after it.
381
381
  - ๐Ÿ”ข `version` accepts a resolver function, and a `405 client_too_old` failure fetches the live WA Web build and applies it on the next reconnect. `fetchLatestWaWebVersion()` now times out after 10s (`timeoutMs` / `signal` options).
382
382
  - ๐Ÿ” Passkey-gated (Shortcake) accounts are surfaced: `connection.update { passkeyRequired: true }` plus a warning when the server sends `passkey_prologue_request`. Expired pairing codes (`refresh_code`) are re-registered automatically with the same code, or surfaced as `connection.update { pairingCodeExpired: true }`.
383
383
  - ๐Ÿข `onWhatsAppUsername()` also reports `isBusiness` / `pnJid`; the USync business parser treats a `<error/>` child as "not a business" instead of failing the whole query, and `withBusinessProtocol(null)` asks for the verified name only.
384
+ - ๐Ÿ–ผ๏ธ Added [image polls](#%EF%B8%8F-image-poll): `sendMessage(jid, { imagePoll })` uploads one image per option, hashes each option over its uploaded `fileSha256`, sends the `pollCreationMessageV3` parent with `pollContentType: IMAGE`, then one `pollCreationOptionImageMessage` child per option associated back by `MEDIA_POLL`. `hashImagePollOption()` is exported separately. Ported from [`@rennzsync/baileys`](https://github.com/RennZSync/baileys).
385
+ - ๐Ÿงฉ Added [A2UI widgets](#-a2ui-widget): `sendMessage(jid, { a2ui })` with a declarative builder (`Text`, `Image`, `Video`, `Button`, `Card`, `Column`, `Row`, `Divider`, `CheckBox`, `TextField`, `ChoicePicker`, plus `listCard`) compiled into `interactiveMessage.bloksWidget`. Ported from [`@rennzsync/baileys`](https://github.com/RennZSync/baileys), rebuilt on this fork's own interactive path so the `<biz>` node and button shapes are the existing ones.
386
+ - ๐Ÿ™Š Added the [`exclusive`](#-exclusive-message) send option: a group message addressed to a single member (`to=<group> participant=<member>`, per-device encryption instead of the group sender key), so no other member receives the stanza. The recipient comes from the quoted message's author, or from an explicit jid.
387
+ - ๐Ÿšซ Added [`sock.ignoreKey()`](#-ignoring-inbound-stanzas): drops matching inbound stanzas before any handler โ€” including the decryption and persistence paths โ€” while still acking so the server stops redelivering. Matches by `remoteJid` / `fromMe` / `id` / `participant` (PN โ†” LID alt attributes included) or by predicate, over `message`, `receipt`, `notification`, `presence`, `chatstate` and `call`. `category='peer'` traffic from your own devices is never dropped, so an app-state key share cannot be filtered away by accident.
388
+ - ๐Ÿ›ก๏ธ Added [`createAnchorGuard()`](#%EF%B8%8F-anchor-guard) / `detectBug()`: flags payloads that force-close WhatsApp clients โ€” invisible and combining character floods, mention bombs, oversized native-flow / list / carousel payloads, broken `buttonParamsJson`, over-deep or circular structures โ€” then deletes them and optionally blocks the sender or kicks on burst. Ported from [`@rexxhayanasi/elaina-anchorguard`](https://www.npmjs.com/package/@rexxhayanasi/elaina-anchorguard) (MIT) via [`@rennzsync/baileys`](https://github.com/RennZSync/baileys).
389
+ - ๐Ÿ—“๏ธ Sent `encEventResponseMessage` and `EVENT_EDIT` secrets as `type=event`. Only `eventMessage` was matched, so event responses went out as `type=text` โ€” a pairing a real client never sends, and the server validates stanza shape.
390
+ - ๐Ÿงพ Stamped `mediatype=group_history` on `messageHistoryBundle` sends, which previously carried no media type at all.
391
+ - ๐Ÿ”‘ `useMultiFileAuthState(folder, logger)` and `useSingleFileAuthState(file, logger)` accept a logger. Credential write failures were swallowed entirely (the internal logger was hard-wired to `null`), so a full disk or a permission error lost the session and looked like a random logout on the next boot. Corrupt or unreadable key files are reported too, and `_destroy()` releases the process exit hooks a long-lived multi-session host would otherwise leak.
384
392
 
385
393
  ## ๐Ÿ“จ Messages Handling & Compatibility
386
394
 
@@ -390,6 +398,8 @@ and the sections after it.
390
398
  - ๐Ÿ‘‰๐Ÿป [Interactive Message](#-sending-interactive-messages) (buttons, lists, native flows, templates, carousels).
391
399
  - ๐ŸŽž๏ธ [Status Mention Message](#%EF%B8%8F-status-mention)
392
400
  - ๐Ÿ“ฆ [Sticker Pack Message](#-sticker-pack)
401
+ - ๐Ÿ–ผ๏ธ [Image Poll](#%EF%B8%8F-image-poll)
402
+ - ๐Ÿงฉ [A2UI Widget](#-a2ui-widget)
393
403
  - โœจ [Rich Response Message](#-rich-response)
394
404
  - ๐Ÿงพ [Message with Code Blocks](#-message-with-code-block)
395
405
  - [๐ŸŒ Message with Inline Entities](#-message-with-inline-entities)
@@ -406,6 +416,7 @@ and the sections after it.
406
416
  - ๐Ÿ“ฃ [`mentionAll`](#-mention) - Mention all group participants without requiring their JIDs in `mentions` or `mentionedJid`
407
417
  - ๐Ÿ”ง [`ephemeral`](#-ephemeral), [`groupStatus`](#%E2%80%8D%E2%80%8D-group-status), [`isLottie`](#-lottie-sticker), [`spoiler`](#-spoiler), [`viewOnce`](#%EF%B8%8F-view-once), [`viewOnceV2`](#%EF%B8%8F-view-once-v2), [`viewOnceV2Extension`](#%EF%B8%8F-view-once-v2-extension), [`interactiveAsTemplate`](#%EF%B8%8F-interactive) - Message wrappers
408
418
  - ๐Ÿ”’ [`secureMetaServiceLabel`](#%EF%B8%8F-secure-meta-service-label) - Secure meta service label on message
419
+ - ๐Ÿ™Š [`exclusive`](#-exclusive-message) - Send into a group addressed to one member only
409
420
  - ๐Ÿ“„ [`raw`](#-raw) - Build your message manually **(DO NOT USE FOR EXPLOITATION)**
410
421
  - ๐ŸŽž๏ธ [`statusPrivacy`](#%EF%B8%8F-status-mention) - Control who receives a status broadcast (`contacts` | `allowlist` | `denylist`)
411
422
 
@@ -768,6 +779,30 @@ sock.sendMessage(jid, {
768
779
  })
769
780
  ```
770
781
 
782
+ ### ๐Ÿ–ผ๏ธ Image Poll
783
+
784
+ A poll whose options are images instead of text.
785
+
786
+ ```javascript
787
+ sock.sendMessage(jid, {
788
+ imagePoll: {
789
+ name: '๐Ÿ–ผ๏ธ Pick your favourite',
790
+ selectableCount: 1, // --- Optional, defaults to 1
791
+ options: [
792
+ { name: 'Gambar 1', image: { url: 'https://example.com/1.jpg' } },
793
+ { name: 'Gambar 2', image: { url: 'https://example.com/2.jpg' } }
794
+ ]
795
+ }
796
+ }, {
797
+ quoted: message
798
+ })
799
+ ```
800
+
801
+ Each option's image is uploaded first, because the option hash covers the uploaded media's `fileSha256` โ€” `sha256(hex(sha256(name)) + base64(fileSha256))`, exported as `hashImagePollOption(name, fileSha256)` if you need it on its own. The parent goes out as `pollCreationMessageV3` with `pollContentType: IMAGE` carrying only the names and hashes; the images follow as one `pollCreationOptionImageMessage` each, tied back to the parent by a `MEDIA_POLL` association (the same mechanism albums use). A vote carries the hash, not the name.
802
+
803
+ > [!WARNING]
804
+ > This is a client-side implementation of a message type WhatsApp's proto defines but no Baileys fork ships a builder for. Treat it as experimental and check how it renders on your target client before relying on it.
805
+
771
806
  ### ๐Ÿ’ญ Button Response
772
807
 
773
808
  ```javascript
@@ -2708,6 +2743,145 @@ sock.CLIENT_PERSIST_GQL_IDS;
2708
2743
  > [!WARNING]
2709
2744
  > These calls leave the WhatsApp WebSocket and hit Meta's HTTPS endpoints directly. They have no built-in timeout, so wrap them in your own if a hung request would stall your bot.
2710
2745
 
2746
+ ### ๐Ÿงฉ A2UI Widget
2747
+
2748
+ Declarative widgets sent as `interactiveMessage.bloksWidget`. Every builder method registers a component and returns its id, so a tree is written bottom-up.
2749
+
2750
+ ```javascript
2751
+ import { A2UI } from "@violetix/baileys";
2752
+
2753
+ const ui = new A2UI();
2754
+ const title = ui.text("Halo!", { variant: "h1" });
2755
+ const button = ui.button(ui.text("Klik saya"), { action: { name: "noop" } });
2756
+ ui.root([ui.card(ui.column([title, button]))]);
2757
+
2758
+ await sock.sendMessage(jid, { a2ui: ui, text: "Widget", footer: "A2UI" });
2759
+
2760
+ // --- product list shortcut, its own payload shape
2761
+ const list = new A2UI().listCard({
2762
+ title: "Menu",
2763
+ items: [{ title: "Nasi Goreng", price: "Rp15.000" }, { title: "Es Teh", price: "Rp5.000" }]
2764
+ });
2765
+ await sock.sendMessage(jid, { a2ui: list, text: "Pesan menu" });
2766
+
2767
+ // --- widget as the whole message, plus native flow buttons
2768
+ await sock.sendMessage(jid, {
2769
+ a2ui: ui,
2770
+ singleScreen: true,
2771
+ nativeFlow: [{ text: "๐ŸŒ Visit", url: "https://example.com" }]
2772
+ });
2773
+ ```
2774
+
2775
+ | Component | Builder |
2776
+ | -------------- | ------------------------------------------------------------------------ |
2777
+ | `Text` | `ui.text(text, { variant })` โ€” `variant` defaults to `body` |
2778
+ | `Image` | `ui.image(url, { variant, fit })` โ€” `fit` defaults to `cover` |
2779
+ | `Video` | `ui.video(url)` |
2780
+ | `Button` | `ui.button(childId, { variant, action })` |
2781
+ | `Card` / `Column` / `Row` | `ui.card(childId)`, `ui.column(ids, { justify, align })`, `ui.row(ids)` |
2782
+ | `CheckBox` / `TextField` / `ChoicePicker` / `Divider` | `ui.checkbox(label, { value })`, `ui.textField(label, { variant })`, `ui.choicePicker(label, options, { variant, displayStyle, filterable })`, `ui.divider()` |
2783
+
2784
+ Send options on the content: `text` (body), `footer`, `singleScreen` (no bubble around the widget), `nativeFlow` (this fork's usual [button shapes](#%EF%B8%8F-interactive)), `messageSecret`, `a2uiType`, `a2uiWrapped: false` (emit a bare `{ components }` payload instead of the `createSurface` envelope). `nativeFlowMessage` is always attached โ€” it is what keys the interactive render and what makes the `<biz>` node go out.
2785
+
2786
+ > [!WARNING]
2787
+ > Internal WhatsApp format. A send can succeed with no error while the widget renders as nothing on the recipient's client. Test on a throwaway number first.
2788
+
2789
+ ### ๐Ÿ™Š Exclusive Message
2790
+
2791
+ Sends into a group but addressed to **one member**. Other members never receive the stanza at all, so there is nothing for them to see or decrypt.
2792
+
2793
+ ```javascript
2794
+ // the quoted message names the recipient โ€” its author
2795
+ await sock.sendMessage(m.chat, { text: "Hi, only you can see this." }, { quoted: m, exclusive: true });
2796
+
2797
+ // or address someone else explicitly, in the group's own addressing mode
2798
+ await sock.sendMessage(groupJid, { text: "psst" }, { exclusive: "43411111111111@lid" });
2799
+ ```
2800
+
2801
+ `exclusive: true` requires `quoted` โ€” the quoted message's author is the only place the recipient comes from, and it already carries the group's addressing mode (a LID-addressed group quotes `@lid`, a PN one quotes `@s.whatsapp.net`), so nothing has to be mapped. Without a quote, or with a quote that has no `participant` (a 1:1 quote), the send throws. So does `exclusive` outside a group.
2802
+
2803
+ How it works: the stanza goes out as `to=<group> participant=<member>` with per-device `pkmsg`/`msg` encryption instead of the group sender key, reusing the retry-resend path. A `skmsg` stanza is fanned out to the whole group by the server, so sender-key sends can never be targeted. One stanza per device of the recipient, all sharing the message id.
2804
+
2805
+ > [!NOTE]
2806
+ > Your own linked devices are not addressed, so the message does not appear in the group on your phone โ€” only `messages.upsert` fires locally (via `emitOwnEvents`). The recipient sees an ordinary group message; nothing marks it as private on their side.
2807
+
2808
+ ### ๐Ÿšซ Ignoring Inbound Stanzas
2809
+
2810
+ `sock.ignoreKey(input)` drops matching inbound stanzas **before any handler runs** โ€” including the decryption and persistence paths. The ack is still sent, so the server stops redelivering them. It returns an unregister function.
2811
+
2812
+ ```javascript
2813
+ // --- Descriptor: drop everything from one peer
2814
+ const off = sock.ignoreKey({ remoteJid: "6281111111111@s.whatsapp.net" });
2815
+
2816
+ // Only that peer's messages; receipts and presence still arrive
2817
+ sock.ignoreKey({ remoteJid: "6281111111111@s.whatsapp.net", only: ["message"] });
2818
+
2819
+ // Several chats at once (array entries OR)
2820
+ sock.ignoreKey({ remoteJid: ["111111@g.us", "222222@g.us"] });
2821
+
2822
+ // Your own outbound echoes from other devices
2823
+ sock.ignoreKey({ fromMe: true, only: ["message"] });
2824
+
2825
+ off(); // unregister
2826
+
2827
+ // --- Predicate: anything the descriptor cannot express
2828
+ sock.ignoreKey((m) => m.kind === "message" && isJidGroup(m.remoteJid ?? ""));
2829
+ sock.ignoreKey((m) => isJidStatusBroadcast(m.remoteJid ?? ""));
2830
+ ```
2831
+
2832
+ | Field | Type | Notes |
2833
+ | ------------- | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
2834
+ | `remoteJid` | `string \| string[]` | Chat JID. Array entries OR. Also matched against the alt `sender_pn` / `sender_lid` attributes, so one JID form catches the other. |
2835
+ | `fromMe` | `boolean` | Whether the stanza was sent by this account, resolved against both your PN and LID. |
2836
+ | `id` | `string` | Stanza id. |
2837
+ | `participant` | `string` | Author in groups / broadcasts. Alt forms are matched too. |
2838
+ | `only` | `('message' \| 'receipt' \| 'notification' \| 'presence' \| 'chatstate' \| 'call')[]` | Restrict to specific tags. Default: all six. |
2839
+
2840
+ Top-level fields AND together; the `remoteJid` array ORs. At least one of `remoteJid` / `fromMe` / `id` / `participant` is required โ€” empty descriptors throw, as do unknown `only` values.
2841
+
2842
+ A predicate receives the already-parsed stanza: `{ kind, remoteJid, fromMe, id, participant }`, with `remoteJid` and `participant` device-stripped so they line up with `event.key.remoteJid`. Return `true` to drop. A predicate that throws is logged and the stanza is kept.
2843
+
2844
+ > [!IMPORTANT]
2845
+ > `category='peer'` messages from your own devices are **never** dropped, whatever the filter says. They carry app-state key shares, history sync and PDO responses between your devices, so a `{ fromMe: true }` filter would otherwise swallow the app-state key share and leave the collections blocked on a key they re-request on every sync. The exemption applies only when the sender really is this account, so a foreign stanza cannot dodge a filter by stamping the attribute. Stream control and the auth-critical `success` / `failure` tags bypass filters as well.
2846
+
2847
+ ### ๐Ÿ›ก๏ธ Anchor Guard
2848
+
2849
+ Detects payloads that force-close WhatsApp clients, deletes them, and optionally blocks the sender.
2850
+
2851
+ ```javascript
2852
+ import { createAnchorGuard, detectBug } from "@violetix/baileys";
2853
+
2854
+ const guard = createAnchorGuard(sock, {
2855
+ blockOnBug: true,
2856
+ guardOutgoing: true, // also reject your own crash payloads before they are sent
2857
+ onDetect: ({ direction, jid, reasons }) => console.log("๐Ÿ›ก๏ธ", direction, jid, reasons),
2858
+ });
2859
+
2860
+ // one-off check without attaching anything
2861
+ const { flagged, reasons } = detectBug(msg.message);
2862
+
2863
+ guard.stop(); // detach listeners and unwrap sendMessage
2864
+ ```
2865
+
2866
+ Flags invisible and combining character floods, mention bombs, oversized native-flow / list / carousel payloads, `buttonParamsJson` that is broken or huge, excessive newlines, over-deep, over-wide and circular structures, and โ€” when you pass `proto` โ€” an encoded size over `maxBytes`.
2867
+
2868
+ | Option | Default | Description |
2869
+ | --------------------------------- | -------- | ---------------------------------------------------------------------------------------- |
2870
+ | `autoDelete` | `true` | Delete a flagged inbound message. |
2871
+ | `deleteMode` | `'auto'` | `'auto'` revokes your own messages and deletes others locally; `'everyone'` always revokes. |
2872
+ | `guardIncoming` | `true` | Guard `messages.upsert`. |
2873
+ | `guardOutgoing` | `false` | Wrap `sock.sendMessage` and throw on a crash payload. |
2874
+ | `blockOnBug` | `false` | Block the sender of a flagged message. |
2875
+ | `selfOnly` | `false` | Only guard your own chats. |
2876
+ | `burstThreshold` / `burstWindowMs` | `0` / `4000` | Messages from one sender inside the window before it counts as a burst. `0` disables. |
2877
+ | `kickOnBurst` | `false` | Remove the sender from the group on burst (needs admin). |
2878
+ | `guardGroupAdds` | `false` | Flag / kick when a burst-flagged inviter adds you to a group. |
2879
+ | `metaAiNumbers` | `false` | Guard Meta AI's own numbers instead of exempting them. |
2880
+ | `thresholds` | `{}` | Override any of `ANCHORGUARD_DEFAULTS`. |
2881
+
2882
+ > [!NOTE]
2883
+ > `guardOutgoing` replaces `sock.sendMessage` with a wrapper, which is why it is off by default โ€” turn it on knowingly if your own code also wraps `sendMessage`. `stop()` only unwinds its own wrapper, so a wrapper you installed afterwards survives.
2884
+
2711
2885
  ### ๐Ÿ“ก Events
2712
2886
 
2713
2887
  ```javascript
@@ -2,7 +2,7 @@ import { proto } from '../../WAProto/index.js';
2
2
  import { makeLibSignalRepository } from '../Signal/libsignal.js';
3
3
  import { Browsers } from '../Utils/browser-utils.js';
4
4
  import logger from '../Utils/logger.js';
5
- const version = [2, 3000, 1048107131];
5
+ const version = [2, 3000, 1053032355];
6
6
  export const UNAUTHORIZED_CODES = [401, 403, 419];
7
7
  export const BIZ_BOT_SUPPORT_PAYLOAD = '{"version":1,"is_ai_message":true,"should_upload_client_logs":false,"should_show_system_message":false,"ticket_id":"7004947587700716","citation_items":[],"ticket_locale":"us"}';
8
8
  export const DEFAULT_ORIGIN = 'https://web.whatsapp.com';
@@ -68,7 +68,9 @@ export const DEFAULT_CONNECTION_CONFIG = {
68
68
  fireInitQueries: true,
69
69
  auth: undefined,
70
70
  markOnlineOnConnect: true,
71
- syncFullHistory: true,
71
+ // FULL history is dropped by shouldSyncHistoryMessage below anyway; asking for it only adds
72
+ // reconnect load (upstream default is false too)
73
+ syncFullHistory: false,
72
74
  patchMessageBeforeSending: msg => msg,
73
75
  shouldSyncHistoryMessage: ({ syncType }) => {
74
76
  return syncType !== proto.HistorySync.HistorySyncType.FULL;
@@ -1077,7 +1077,9 @@ export const makeChatsSocket = (config) => {
1077
1077
  if (attrs.timestamp) {
1078
1078
  let { lastAccountSyncTimestamp } = authState.creds;
1079
1079
  if (lastAccountSyncTimestamp) {
1080
- await cleanDirtyBits('account_sync', lastAccountSyncTimestamp);
1080
+ // async ws handler: a rejection here would be unhandled and kill the process
1081
+ await cleanDirtyBits('account_sync', lastAccountSyncTimestamp)
1082
+ .catch(error => logger.warn({ error }, 'failed to clean account_sync dirty bits'));
1081
1083
  }
1082
1084
  lastAccountSyncTimestamp = +attrs.timestamp;
1083
1085
  ev.emit('creds.update', { lastAccountSyncTimestamp });
@@ -23,7 +23,7 @@ export const makeGroupsSocket = (config) => {
23
23
  }
24
24
  const promise = groupQuery(jid, 'get', [{ tag: 'query', attrs: { request: 'interactive' } }])
25
25
  .then(result => extractGroupMetadata(result))
26
- .catch(() => null)
26
+ // no swallow: a null here made group sends go to zero recipients silently
27
27
  .finally(() => inflightGroupMetadata.delete(jid));
28
28
  inflightGroupMetadata.set(jid, promise);
29
29
  return promise;
@@ -69,8 +69,13 @@ export const makeGroupsSocket = (config) => {
69
69
  if (attrs.type !== 'groups') {
70
70
  return;
71
71
  }
72
- await groupFetchAllParticipating();
73
- await sock.cleanDirtyBits('groups');
72
+ try {
73
+ await groupFetchAllParticipating();
74
+ await sock.cleanDirtyBits('groups');
75
+ }
76
+ catch (error) {
77
+ config.logger?.warn({ error }, 'failed to handle dirty groups');
78
+ }
74
79
  });
75
80
  return {
76
81
  ...sock,
@@ -636,6 +636,7 @@ declare function makeWASocket(config: import('../Types/Socket.js').SocketConfig)
636
636
  updateMemberLabel: (jid: any, memberLabel: any) => Promise<any>;
637
637
  updateMediaMessage: (message: any) => Promise<any>;
638
638
  sendMessage: (jid: any, content: any, options?: {}) => Promise<import("../index.js").proto.WebMessageInfo | undefined>;
639
+ groupStatus: (jid: any, content?: any, options?: {}) => Promise<import("../index.js").proto.WebMessageInfo | undefined>;
639
640
  executeWMexQuery: (variables: any, queryId: any, dataPath: any) => Promise<any>;
640
641
  newsletterCreate: (name: any, description: any) => Promise<{
641
642
  id: any;
@@ -875,6 +876,12 @@ declare function makeWASocket(config: import('../Types/Socket.js').SocketConfig)
875
876
  signalRepository: any;
876
877
  user: any;
877
878
  generateMessageTag: () => string;
879
+ /**
880
+ * Drops matching inbound stanzas before any handler โ€” including the decryption and
881
+ * persistence paths โ€” sees them. The ack is still sent so the server stops re-delivering.
882
+ * Returns an unregister function.
883
+ */
884
+ ignoreKey: (input: import('../Utils/ignore-key.js').WaIgnoreKeyInput) => () => void;
878
885
  query: (node: any, timeoutMs: any) => Promise<any>;
879
886
  waitForMessage: (msgId: any, timeoutMs?: any) => Promise<any>;
880
887
  waitForSocketOpen: () => Promise<void>;
@@ -5,7 +5,7 @@ import Long from 'long';
5
5
  import { proto } from '../../WAProto/index.js';
6
6
  import { DEFAULT_CACHE_TTLS, KEY_BUNDLE_TYPE, MIN_PREKEY_COUNT, PLACEHOLDER_MAX_AGE_SECONDS, STATUS_EXPIRY_SECONDS } from '../Defaults/index.js';
7
7
  import { ReachoutTimelockEnforcementType, WAMessageStatus, WAMessageStubType } from '../Types/index.js';
8
- import { ACCOUNT_RESTRICTED_TEXT, aesDecryptCTR, aesEncryptGCM, cleanMessage, Curve, decodeMediaRetryNode, decodeMessageNode, decryptMessageNode, setBotMessageSecret, delay, derivePairingCodeKey, encodeBigEndian, encodeSignedDeviceIdentity, extractAddressingContext, extractE2ESessionFromRetryReceipt, getCallStatusFromNode, getHistoryMsg, getNextPreKeys, getStatusFromReceiptType, handleIdentityChange, hkdf, MISSING_KEYS_ERROR_TEXT, NACK_REASONS, NO_MESSAGE_FOUND_ERROR_TEXT, SERVER_ERROR_CODES, toNumber, unixTimestampSeconds, xmppPreKey, xmppSignedPreKey } from '../Utils/index.js';
8
+ import { ACCOUNT_RESTRICTED_TEXT, aesDecryptCTR, aesEncryptGCM, cleanMessage, Curve, decodeMediaRetryNode, decodeMessageNode, decryptMessageNode, setBotMessageSecret, delay, derivePairingCodeKey, encodeBigEndian, encodeSignedDeviceIdentity, extractAddressingContext, extractE2ESessionFromRetryReceipt, getCallStatusFromNode, getHistoryMsg, getNextPreKeys, getStatusFromReceiptType, handleIdentityChange, hkdf, MISSING_KEYS_ERROR_TEXT, NACK_REASONS, NO_MESSAGE_FOUND_ERROR_TEXT, SERVER_ERROR_CODES, toNumber, unixTimestampSeconds, unpadRandomMax16, xmppPreKey, xmppSignedPreKey } from '../Utils/index.js';
9
9
  import { makeMutex } from '../Utils/make-mutex.js';
10
10
  import { makeOfflineNodeProcessor } from '../Utils/offline-node-processor.js';
11
11
  import { buildAckStanza } from '../Utils/stanza-ack.js';
@@ -394,7 +394,7 @@ export const makeMessagesRecvSocket = (config) => {
394
394
  }
395
395
  };
396
396
  const sendMessageAck = async (node, errorCode) => {
397
- const stanza = buildAckStanza(node, errorCode, authState.creds.me.id);
397
+ const stanza = buildAckStanza(node, errorCode, authState.creds.me?.id);
398
398
  logger.debug({ recv: { tag: node.tag, attrs: node.attrs }, sent: stanza.attrs }, 'sent ack');
399
399
  await sendNode(stanza);
400
400
  };
@@ -657,7 +657,7 @@ export const makeMessagesRecvSocket = (config) => {
657
657
  };
658
658
  break;
659
659
  case 'modify':
660
- const oldNumber = getBinaryNodeChildren(child, 'participant').map(p => p.attrs.jid);
660
+ const oldNumber = getBinaryNodeChildren(child, 'participant').map(p => JSON.stringify({ id: p.attrs.jid, phoneNumber: p.attrs.phone_number, lid: p.attrs.lid }));
661
661
  msg.messageStubParameters = oldNumber || [];
662
662
  msg.messageStubType = WAMessageStubType.GROUP_PARTICIPANT_CHANGE_NUMBER;
663
663
  break;
@@ -3,7 +3,7 @@ import { Boom } from '@hapi/boom';
3
3
  import { randomBytes } from 'crypto';
4
4
  import { proto } from '../../WAProto/index.js';
5
5
  import { BIZ_BOT_SUPPORT_PAYLOAD, DEFAULT_CACHE_TTLS, WA_DEFAULT_EPHEMERAL } from '../Defaults/index.js';
6
- import { aggregateMessageKeysNotFromMe, assertMediaContent, assertMeId, bindWaitForEvent, decryptMediaRetryData, DEF_MEDIA_HOST, delay, encodeNewsletterMessage, encodeSignedDeviceIdentity, encodeWAMessage, encryptMediaRetryRequest, extractDeviceJids, generateMessageIDV2, generateParticipantHashV2, generateWAMessage, generateWAMessageFromContent, getStatusCodeForMediaRetry, getUrlFromDirectPath, getWAUploadToServer, hasValidAlbumMedia, MessageRetryManager, normalizeMessageContent, parseAndInjectE2ESessions, shouldIncludeBizBinaryNode, unixTimestampSeconds } from '../Utils/index.js';
6
+ import { aggregateMessageKeysNotFromMe, assertMediaContent, assertMeId, bindWaitForEvent, decryptMediaRetryData, DEF_MEDIA_HOST, delay, encodeNewsletterMessage, encodeSignedDeviceIdentity, encodeWAMessage, encryptMediaRetryRequest, extractDeviceJids, generateMessageIDV2, generateParticipantHashV2, generateWAMessage, generateWAMessageFromContent, getStatusCodeForMediaRetry, getUrlFromDirectPath, getWAUploadToServer, hashImagePollOption, hasValidAlbumMedia, MessageRetryManager, normalizeMessageContent, parseAndInjectE2ESessions, prepareWAMessageMedia, shouldIncludeBizBinaryNode, unixTimestampSeconds } from '../Utils/index.js';
7
7
  import { AssociationType } from '../Types/index.js';
8
8
  import { setBotMessageSecret } from '../Utils/decode-wa-message.js';
9
9
  import { getUrlInfo } from '../Utils/link-preview.js';
@@ -1041,6 +1041,79 @@ export const makeMessagesSocket = (config) => {
1041
1041
  throw err;
1042
1042
  }
1043
1043
  };
1044
+ /**
1045
+ * Uploads one image per poll option and returns the hashed options for the parent poll
1046
+ * alongside the prepared media the children will carry. The hash covers the uploaded
1047
+ * media's `fileSha256`, so the upload has to happen before the parent is built.
1048
+ */
1049
+ const prepareImagePollOptions = async (imagePoll, genOptions) => {
1050
+ if (!Array.isArray(imagePoll.options) || !imagePoll.options.length) {
1051
+ throw new Boom('Invalid imagePoll options', { statusCode: 400 });
1052
+ }
1053
+ const optionMedia = [];
1054
+ const options = [];
1055
+ for (const option of imagePoll.options) {
1056
+ if (!option?.image) {
1057
+ throw new Boom('every imagePoll option needs an image', { statusCode: 400 });
1058
+ }
1059
+ const media = await prepareWAMessageMedia({ image: option.image }, genOptions);
1060
+ optionMedia.push(media);
1061
+ options.push({
1062
+ optionName: option.name,
1063
+ optionHash: hashImagePollOption(option.name, media.imageMessage?.fileSha256)
1064
+ });
1065
+ }
1066
+ return { optionMedia, options };
1067
+ };
1068
+ /**
1069
+ * Picks the single group member an exclusive message is addressed to.
1070
+ *
1071
+ * `exclusive: true` reads the author of the quoted message, which is why a quote is required:
1072
+ * it is the only place the recipient can come from, and it already carries the group's own
1073
+ * addressing mode (a LID-addressed group quotes `@lid`, a PN one quotes `@s.whatsapp.net`),
1074
+ * so no mapping is needed. Pass a jid instead to address someone else โ€” in the group's mode.
1075
+ */
1076
+ const resolveExclusiveTarget = (options) => {
1077
+ if (typeof options.exclusive === 'string') {
1078
+ return options.exclusive;
1079
+ }
1080
+ const quoted = options.quoted;
1081
+ return quoted?.key?.participant || quoted?.participant || undefined;
1082
+ };
1083
+ /**
1084
+ * Sends into a group but addressed to one member: the stanza goes out as
1085
+ * `to=<group> participant=<member>`, so the server routes it to that member's devices and no
1086
+ * other member receives anything at all. Reuses the retry-resend path โ€” per-device pkmsg/msg
1087
+ * instead of the group sender key, which is what makes the targeting possible: a `skmsg`
1088
+ * stanza is fanned out to the whole group by the server.
1089
+ *
1090
+ * One stanza per device of the recipient, all sharing the message id, because a group
1091
+ * `participant` attribute addresses a device and not an account.
1092
+ */
1093
+ const relayExclusive = async (jid, message, options, relayOpts) => {
1094
+ if (!isJidGroup(jid)) {
1095
+ throw new Boom('exclusive messages are only supported in groups', { statusCode: 400 });
1096
+ }
1097
+ const target = resolveExclusiveTarget(options);
1098
+ if (!target) {
1099
+ throw new Boom('exclusive requires a quoted message to pick the recipient, or an explicit jid', { statusCode: 400 });
1100
+ }
1101
+ const targetUser = jidNormalizedUser(target);
1102
+ const deviceJids = (await getUSyncDevices([targetUser], relayOpts.useUserDevicesCache !== false, false))
1103
+ .map(device => device.jid)
1104
+ .filter(Boolean);
1105
+ // No device list means the member is unknown to usync; addressing the bare jid still
1106
+ // reaches their primary device rather than failing the send outright.
1107
+ const targets = deviceJids.length ? deviceJids : [targetUser];
1108
+ logger.debug({ jid, target: targetUser, devices: targets.length }, 'sending exclusive group message');
1109
+ for (const deviceJid of targets) {
1110
+ await relayMessage(jid, message, {
1111
+ ...relayOpts,
1112
+ participant: { jid: deviceJid, count: 0 }
1113
+ });
1114
+ }
1115
+ return targets;
1116
+ };
1044
1117
  const getMessageType = (message) => {
1045
1118
  if (!message)
1046
1119
  return 'text';
@@ -1055,7 +1128,12 @@ export const makeMessagesSocket = (config) => {
1055
1128
  message.pollUpdateMessage) {
1056
1129
  return 'poll';
1057
1130
  }
1058
- if (message.eventMessage) {
1131
+ // An event response and an EVENT_EDIT secret both belong to the event thread. Only
1132
+ // `eventMessage` was matched here, so those two went out as `media`/`text`; the server
1133
+ // validates stanza shape, which is a pairing a real client never sends.
1134
+ if (message.eventMessage ||
1135
+ message.encEventResponseMessage ||
1136
+ message.secretEncryptedMessage?.secretEncType === proto.Message.SecretEncryptedMessage.SecretEncType.EVENT_EDIT) {
1059
1137
  return 'event';
1060
1138
  }
1061
1139
  if (getMediaType(message) !== '') {
@@ -1115,6 +1193,9 @@ export const makeMessagesSocket = (config) => {
1115
1193
  else if (message.extendedTextMessage?.matchedText || message.groupInviteMessage) {
1116
1194
  return 'url';
1117
1195
  }
1196
+ else if (message.messageHistoryBundle) {
1197
+ return 'group_history';
1198
+ }
1118
1199
  // Lia@Note 02-02-26 --- Add more message type here
1119
1200
  else if ((message.extendedTextMessage?.text || message.conversation || '').includes('://wa.me/c/')) {
1120
1201
  return 'cataloglink';
@@ -1327,7 +1408,23 @@ export const makeMessagesSocket = (config) => {
1327
1408
  await groupToggleEphemeral(jid, value);
1328
1409
  }
1329
1410
  else {
1330
- const fullMsg = await generateWAMessage(jid, content, {
1411
+ // An image poll uploads one image per option before the parent is built: the
1412
+ // parent only carries each option's hash, and the hash covers the uploaded
1413
+ // media's fileSha256. The images follow as children once the parent has a key.
1414
+ let imagePollOptionMedia;
1415
+ let sendContent = content;
1416
+ if ('imagePoll' in content && content.imagePoll) {
1417
+ const prepared = await prepareImagePollOptions(content.imagePoll, {
1418
+ logger,
1419
+ upload: waUploadToServer,
1420
+ mediaCache: config.mediaCache,
1421
+ options: config.options,
1422
+ ...options
1423
+ });
1424
+ imagePollOptionMedia = prepared.optionMedia;
1425
+ sendContent = { ...content, imagePoll: { ...content.imagePoll, options: prepared.options } };
1426
+ }
1427
+ const fullMsg = await generateWAMessage(jid, sendContent, {
1331
1428
  logger,
1332
1429
  userJid,
1333
1430
  getUrlInfo: text => getUrlInfo(text, {
@@ -1354,12 +1451,14 @@ export const makeMessagesSocket = (config) => {
1354
1451
  const isEditMsg = 'edit' in content && !!content.edit;
1355
1452
  const isPinMsg = 'pin' in content && !!content.pin;
1356
1453
  const isKeepMsg = 'keep' in content && !!content.keep;
1357
- const isPollMsg = 'poll' in content && !!content.poll;
1454
+ const isPollMsg = ('poll' in content && !!content.poll) || !!imagePollOptionMedia;
1358
1455
  const isQuizMsg = 'poll' in content && !!content.poll.pollType;
1359
1456
  const isAiMsg = 'ai' in content && !!content.ai;
1360
1457
  const isNeedBizAttrs = 'secureMetaServiceLabel' in content && !!content.secureMetaServiceLabel;
1361
- const additionalAttributes = options.additionalAttributes || {};
1362
- const additionalNodes = options.additionalNodes || [];
1458
+ // copies: these get .edit / meta / bot added below, which must not leak into the
1459
+ // caller's reused options object (stacked nodes, stray edit turning sends into deletes)
1460
+ const additionalAttributes = { ...options.additionalAttributes };
1461
+ const additionalNodes = [...(options.additionalNodes || [])];
1363
1462
  // required for delete
1364
1463
  if (isDeleteMsg || isKeepMsg) {
1365
1464
  // if the chat is a group, and I am not the author, then delete the message as an admin
@@ -1415,9 +1514,8 @@ export const makeMessagesSocket = (config) => {
1415
1514
  },
1416
1515
  content: undefined
1417
1516
  });
1418
- delete content.ai;
1419
1517
  }
1420
- await relayMessage(jid, fullMsg.message, {
1518
+ const relayOpts = {
1421
1519
  messageId: fullMsg.key.id,
1422
1520
  useCachedGroupMetadata: options.useCachedGroupMetadata,
1423
1521
  addBizAttributes: isNeedBizAttrs,
@@ -1425,10 +1523,42 @@ export const makeMessagesSocket = (config) => {
1425
1523
  statusPrivacy: options.statusPrivacy,
1426
1524
  additionalAttributes,
1427
1525
  additionalNodes
1428
- });
1526
+ };
1527
+ // children (poll option images, album items) must honour `exclusive` too
1528
+ const relay = (message, opts) => options.exclusive
1529
+ ? relayExclusive(jid, message, options, opts)
1530
+ : relayMessage(jid, message, opts);
1531
+ await relay(fullMsg.message, relayOpts);
1429
1532
  if (config.emitOwnEvents) {
1430
1533
  setImmediate(() => emitOwnUpsert(fullMsg));
1431
1534
  }
1535
+ // The option images follow the parent, each pointing back at it. Same shape as
1536
+ // the album loop below, with MEDIA_POLL instead of MEDIA_ALBUM.
1537
+ if (imagePollOptionMedia?.length) {
1538
+ for (const media of imagePollOptionMedia) {
1539
+ const optionMsg = generateWAMessageFromContent(jid, {
1540
+ pollCreationOptionImageMessage: { message: media },
1541
+ messageContextInfo: {
1542
+ messageAssociation: {
1543
+ parentMessageKey: fullMsg.key,
1544
+ associationType: AssociationType.MEDIA_POLL
1545
+ }
1546
+ }
1547
+ }, {
1548
+ userJid,
1549
+ messageId: generateMessageIDV2(userJid)
1550
+ });
1551
+ await relay(optionMsg.message, {
1552
+ messageId: optionMsg.key.id,
1553
+ useCachedGroupMetadata: options.useCachedGroupMetadata,
1554
+ additionalAttributes,
1555
+ additionalNodes: []
1556
+ });
1557
+ if (config.emitOwnEvents) {
1558
+ process.nextTick(() => emitOwnUpsert(optionMsg));
1559
+ }
1560
+ }
1561
+ }
1432
1562
  // Lia@Changes 31-01-26 --- Add support for album messages
1433
1563
  // Lia@Note 06-02-26 --- Refactored to reduce high RSS usage (โ โ•ฅโ ๏นโ โ•ฅโ )
1434
1564
  if ('album' in content) {
@@ -1451,7 +1581,7 @@ export const makeMessagesSocket = (config) => {
1451
1581
  parentMessageKey: fullMsg.key,
1452
1582
  associationType: AssociationType.MEDIA_ALBUM
1453
1583
  };
1454
- await relayMessage(jid, albumMsg.message, {
1584
+ await relay(albumMsg.message, {
1455
1585
  messageId: albumMsg.key.id,
1456
1586
  useCachedGroupMetadata: options.useCachedGroupMetadata,
1457
1587
  addBizAttributes: isNeedBizAttrs,
@@ -25,6 +25,12 @@ export function makeSocket(config: any): {
25
25
  signalRepository: any;
26
26
  readonly user: any;
27
27
  generateMessageTag: () => string;
28
+ /**
29
+ * Drops matching inbound stanzas before any handler โ€” including the decryption and
30
+ * persistence paths โ€” sees them. The ack is still sent so the server stops re-delivering.
31
+ * Returns an unregister function.
32
+ */
33
+ ignoreKey: (input: import('../Utils/ignore-key.js').WaIgnoreKeyInput) => () => void;
28
34
  query: (node: any, timeoutMs: any) => Promise<any>;
29
35
  waitForMessage: (msgId: any, timeoutMs?: any) => Promise<any>;
30
36
  waitForSocketOpen: () => Promise<void>;