sauruspro-bail 5.0.1 → 5.0.2

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,521 +1,739 @@
1
1
  /**
2
- * Do not remove this watermark.
2
+ * SPR - Selective / Private Relay for sauruspro-bail
3
3
  *
4
- * NIXCODE - Advanced WhatsApp Send Private Relay
4
+ * Engine kirim pesan grup dengan kontrol siapa yang bisa membaca apa:
5
+ * - public : membaca pesan umum (menerima sender key sekali pakai)
6
+ * - personal : membaca pesan khusus miliknya sendiri (enkripsi 1-lawan-1)
7
+ * - hidden : tidak menerima apa-apa
5
8
  *
6
- * Runtime:
7
- * - sauruspro-bail (SPR, ESM)
9
+ * Fitur kirim-per-penerima (privat, broadcast {name}, foto profil per orang,
10
+ * batas target, cache, timeout) diadaptasi dari plugin zapo-js (MIT).
8
11
  *
9
- * Created by Nixel
10
- * WhatsApp: wa.me/6285188349341
11
- * Channel: https://whatsapp.com/channel/0029VbCV1ck8fewpdNb2TY2k
12
- *
13
- * Copyright (c) 2026 Nixel
14
- *
15
- * Permission is granted to use and modify this library
16
- * for personal or commercial projects.
17
- *
18
- * Reuploading, reselling, relicensing, or redistributing
19
- * this library as a standalone product is prohibited.
20
- *
21
- * Do not claim this project as your own original work.
12
+ * Maintained by Saurus / Dinonic.
22
13
  */
23
14
 
24
15
  "use strict";
25
16
 
26
- import { proto } from '../../WAProto/index.js';
17
+ import crypto from 'crypto';
18
+ import { Boom } from '@hapi/boom';
27
19
  import { Curve } from './crypto.js';
28
- import { generateMessageIDV2, generateParticipantHashV2, encodeWAMessage, encodeNewsletterMessage } from './generics.js';
20
+ import { encodeWAMessage, generateMessageIDV2, generateParticipantHashV2 } from './generics.js';
29
21
  import { encodeSignedDeviceIdentity } from './validate-connection.js';
30
- import { normalizeMessageContent } from './messages.js';
31
- import { isHostedLidUser, isHostedPnUser, isLidUser, jidDecode, jidEncode } from '../WABinary/index.js';
32
- import { Boom } from '@hapi/boom';
33
- import crypto from 'crypto';
22
+ import { normalizeMessageContent, prepareWAMessageMedia } from './messages.js';
23
+ import { getMessageReportingToken, shouldIncludeReportingToken } from './reporting-utils.js';
24
+ import { isTcTokenExpired, resolveTcTokenJid } from './tc-token-utils.js';
25
+ import {
26
+ isHostedLidUser,
27
+ isHostedPnUser,
28
+ isJidGroup,
29
+ isLidUser,
30
+ jidDecode,
31
+ jidEncode
32
+ } from '../WABinary/index.js';
33
+
34
+ /* -------------------------------------------------------------------------- */
35
+ /* Konfigurasi & teks default */
36
+ /* -------------------------------------------------------------------------- */
37
+
38
+ export const SPR_LIMITS = Object.freeze({
39
+ targets: 100,
40
+ concurrency: 10,
41
+ encryptConcurrency: 8,
42
+ avatarBudgetMs: 10000,
43
+ avatarHardCapMs: 14000,
44
+ avatarFetchMs: 8000,
45
+ cacheSize: 200
46
+ });
47
+
48
+ export const SPR_TEXT = Object.freeze({
49
+ publicDefault: 'Pesan ini memiliki isi khusus untuk beberapa penerima.',
50
+ templateDefault: 'Halo {name}, ada pesan untukmu.',
51
+ noTarget: 'Tidak ada anggota yang bisa dijadikan target (bot tidak dihitung).',
52
+ emptyPrivate: 'Isi pesan privat kosong.',
53
+ needGroup: 'Perintah ini hanya untuk grup.',
54
+ encryptFailed: 'Enkripsi per-penerima gagal semua. Coba lagi sebentar lagi.'
55
+ });
56
+
57
+ /* -------------------------------------------------------------------------- */
58
+ /* Utilitas kecil (safe / timeout / pool / cache) */
59
+ /* -------------------------------------------------------------------------- */
60
+
61
+ const attempt = async (task, fallback = null) => {
62
+ try {
63
+ return await task();
64
+ } catch {
65
+ return fallback;
66
+ }
67
+ };
34
68
 
69
+ const withTimeout = async (promise, ms, fallback) => {
70
+ let timer;
71
+ const limit = new Promise((resolve) => {
72
+ timer = setTimeout(() => resolve(fallback), ms);
73
+ timer.unref?.();
74
+ });
75
+ try {
76
+ return await Promise.race([promise, limit]);
77
+ } finally {
78
+ clearTimeout(timer);
79
+ }
80
+ };
35
81
 
82
+ const runPool = async (items, limit, task) => {
83
+ const out = new Array(items.length);
84
+ let cursor = 0;
85
+ const worker = async () => {
86
+ while (cursor < items.length) {
87
+ const index = cursor++;
88
+ out[index] = await attempt(() => task(items[index], index));
89
+ }
90
+ };
91
+ await Promise.all(Array.from({ length: Math.min(Math.max(limit, 1), items.length) }, worker));
92
+ return out;
93
+ };
36
94
 
37
- function encodeVarint(value) {
38
- const bytes = [];
39
- while (value >= 128) {
40
- bytes.push((value & 127) | 128);
41
- value >>>= 7;
42
- }
43
- bytes.push(value);
44
- return Buffer.from(bytes);
45
- }
95
+ const makeCache = (size) => {
96
+ const store = new Map();
97
+ return {
98
+ has: (k) => store.has(k),
99
+ get: (k) => store.get(k),
100
+ set: (k, v) => {
101
+ store.set(k, v);
102
+ if (store.size > size) store.delete(store.keys().next().value);
103
+ return v;
104
+ }
105
+ };
106
+ };
46
107
 
47
- export function generateSigningKeyPair() {
48
- const keys = Curve.generateKeyPair();
49
- const pubBuffer = Buffer.concat([Buffer.from([0x05]), Buffer.from(keys.public)]);
50
- return { privateKey: Buffer.from(keys.private), publicKey: pubBuffer };
51
- }
108
+ const avatarBytesCache = makeCache(SPR_LIMITS.cacheSize);
109
+ const avatarMediaCache = makeCache(SPR_LIMITS.cacheSize);
52
110
 
53
- export function getMessageKey(seed) {
54
- const messageKey = crypto.createHmac("sha256", seed).update(Buffer.from([0x01])).digest();
55
- const salt = Buffer.alloc(32, 0);
56
- const info = Buffer.from("WhisperGroup");
57
- const prk = crypto.createHmac("sha256", salt).update(messageKey).digest();
58
- const t1 = crypto.createHmac("sha256", prk).update(Buffer.concat([info, Buffer.from([0x01])])).digest();
59
- const t2 = crypto.createHmac("sha256", prk).update(Buffer.concat([t1, info, Buffer.from([0x02])])).digest();
60
- const okm = Buffer.concat([t1, t2]);
61
- const iv = okm.slice(0, 16);
62
- const cipherKey = okm.slice(16, 48);
63
- return { iv, cipherKey };
64
- }
111
+ const userOf = (jid) => (jid ? jidDecode(String(jid))?.user || String(jid).split('@')[0].split(':')[0] : '');
65
112
 
66
- export function deriveReportingTag(message, msgId, remoteJid) {
67
- if (!message.messageContextInfo) message.messageContextInfo = {};
68
- if (!Buffer.isBuffer(message.messageContextInfo.messageSecret) || message.messageContextInfo.messageSecret.length !== 32) {
69
- message.messageContextInfo.messageSecret = crypto.randomBytes(32);
70
- }
71
- const seed = message.messageContextInfo.messageSecret;
72
- const payload = Buffer.from(proto.Message.encode(message).finish());
73
- const salt = Buffer.alloc(32, 0);
74
- const info = Buffer.concat([
75
- Buffer.from(msgId, "utf8"),
76
- Buffer.from(remoteJid, "utf8"),
77
- Buffer.from(remoteJid, "utf8"),
78
- Buffer.from("Report Token", "utf8")
79
- ]);
80
- const prk = crypto.createHmac("sha256", salt).update(seed).digest();
81
- const t1 = crypto.createHmac("sha256", prk).update(Buffer.concat([info, Buffer.from([0x01])])).digest();
82
- const mac = crypto.createHmac("sha256", t1).update(payload).digest();
83
-
84
- const iv = Buffer.from([0x7a, 0x3b, 0x91, 0xc4, 0x2e, 0x1f, 0xd0, 0x63]);
85
- const vec = Buffer.from([0x29, 0x6b, 0xc3, 0x8a, 0x67, 0x47, 0x95, 0x2f, 0x2c, 0x0a]);
86
- const nonce = mac.subarray(0, 4);
87
- const stream = crypto.createHmac("sha256", iv).update(nonce).digest();
88
-
89
- const raw = Buffer.alloc(10);
90
- for (let i = 0; i < 10; i++) {
91
- raw[i] = vec[i] ^ iv[i & 7];
113
+ /* -------------------------------------------------------------------------- */
114
+ /* Sender key sekali pakai (Signal group cipher) */
115
+ /* -------------------------------------------------------------------------- */
116
+
117
+ const varint = (value) => {
118
+ const out = [];
119
+ let v = value >>> 0;
120
+ while (v >= 0x80) {
121
+ out.push((v & 0x7f) | 0x80);
122
+ v >>>= 7;
92
123
  }
124
+ out.push(v);
125
+ return Buffer.from(out);
126
+ };
93
127
 
94
- const sig = crypto.createHmac("sha256", iv).update(Buffer.concat([nonce, raw])).digest().subarray(0, 2);
95
- const block = Buffer.concat([raw, sig]);
128
+ const fieldVarint = (field, value) => Buffer.concat([varint((field << 3) | 0), varint(value)]);
129
+ const fieldBytes = (field, bytes) => Buffer.concat([varint((field << 3) | 2), varint(bytes.length), bytes]);
96
130
 
97
- for (let i = 0; i < 12; i++) {
98
- block[i] ^= stream[i];
99
- }
131
+ const SIGNAL_VERSION = Buffer.from([0x33]);
100
132
 
101
- return Buffer.concat([nonce, block]);
102
- }
133
+ /**
134
+ * Buat sender key baru yang hanya hidup untuk satu pesan.
135
+ * Karena kuncinya tidak pernah disimpan, hanya device yang diberi SKDM-nya
136
+ * yang bisa membuka pesan grup ini.
137
+ */
138
+ export const createOneShotSenderKey = () => {
139
+ const keyId = crypto.randomBytes(4).readUInt32BE(0) & 0x7fffffff;
140
+ const chainKey = crypto.randomBytes(32);
141
+ const pair = Curve.generateKeyPair();
142
+ const signingPublic = Buffer.concat([Buffer.from([0x05]), Buffer.from(pair.public)]);
143
+
144
+ const deriveCipher = () => {
145
+ const seed = crypto.createHmac('sha256', chainKey).update(Buffer.from([0x01])).digest();
146
+ const okm = Buffer.from(crypto.hkdfSync('sha256', seed, Buffer.alloc(32), 'WhisperGroup', 48));
147
+ return { iv: okm.subarray(0, 16), key: okm.subarray(16, 48) };
148
+ };
103
149
 
104
- export function encryptGroupMessage(plaintext, chainSeed, privateKeyObject, keyId) {
105
- const iteration = 0;
106
- const { iv, cipherKey } = getMessageKey(chainSeed);
107
- const cipher = crypto.createCipheriv("aes-256-cbc", cipherKey, iv);
108
- const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
109
- const idBuf = Buffer.concat([Buffer.from([0x08]), encodeVarint(keyId)]);
110
- const iterBuf = Buffer.concat([Buffer.from([0x10]), encodeVarint(iteration)]);
111
- const cipherBuf = Buffer.concat([Buffer.from([0x1A]), encodeVarint(ciphertext.length), ciphertext]);
112
- const messageProto = Buffer.concat([idBuf, iterBuf, cipherBuf]);
113
- const messageToSign = Buffer.concat([Buffer.from([0x33]), messageProto]);
114
- const signature = Curve.sign(privateKeyObject, messageToSign);
115
- return Buffer.concat([messageToSign, Buffer.from(signature)]);
116
- }
150
+ return {
151
+ keyId,
152
+ /** Isi senderKeyDistributionMessage.axolotlSenderKeyDistributionMessage */
153
+ distribution() {
154
+ const body = Buffer.concat([
155
+ fieldVarint(1, keyId),
156
+ fieldVarint(2, 0),
157
+ fieldBytes(3, chainKey),
158
+ fieldBytes(4, signingPublic)
159
+ ]);
160
+ return Buffer.concat([SIGNAL_VERSION, body]);
161
+ },
162
+ /** Enkripsi payload menjadi SenderKeyMessage (iterasi 0) */
163
+ seal(plaintext) {
164
+ const { iv, key } = deriveCipher();
165
+ const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
166
+ const sealed = Buffer.concat([cipher.update(plaintext), cipher.final()]);
167
+ const body = Buffer.concat([fieldVarint(1, keyId), fieldVarint(2, 0), fieldBytes(3, sealed)]);
168
+ const signed = Buffer.concat([SIGNAL_VERSION, body]);
169
+ const signature = Buffer.from(Curve.sign(pair.private, signed));
170
+ return Buffer.concat([signed, signature]);
171
+ }
172
+ };
173
+ };
117
174
 
118
- export function createSKDM(keyId, iteration, chainSeed, signingPubKey) {
119
- const idBuf = Buffer.concat([Buffer.from([0x08]), encodeVarint(keyId)]);
120
- const iterBuf = Buffer.concat([Buffer.from([0x10]), encodeVarint(iteration)]);
121
- const chainBuf = Buffer.concat([Buffer.from([0x1A]), encodeVarint(chainSeed.length), chainSeed]);
122
- const signBuf = Buffer.concat([Buffer.from([0x22]), encodeVarint(signingPubKey.length), signingPubKey]);
123
- const message = Buffer.concat([idBuf, iterBuf, chainBuf, signBuf]);
124
- return Buffer.concat([Buffer.from([0x33]), message]);
125
- }
175
+ /* -------------------------------------------------------------------------- */
176
+ /* Helper tipe pesan */
177
+ /* -------------------------------------------------------------------------- */
178
+
179
+ const mediaTypeOf = (m) => {
180
+ if (!m) return '';
181
+ if (m.imageMessage) return 'image';
182
+ if (m.videoMessage) return m.videoMessage.gifPlayback ? 'gif' : 'video';
183
+ if (m.audioMessage) return m.audioMessage.ptt ? 'ptt' : 'audio';
184
+ if (m.contactMessage) return 'vcard';
185
+ if (m.documentMessage) return 'document';
186
+ if (m.contactsArrayMessage) return 'contact_array';
187
+ if (m.liveLocationMessage) return 'livelocation';
188
+ if (m.stickerMessage || m.stickerPackMessage) return 'sticker';
189
+ if (m.listMessage) return 'list';
190
+ if (m.listResponseMessage) return 'list_response';
191
+ if (m.buttonsResponseMessage) return 'buttons_response';
192
+ if (m.orderMessage) return 'order';
193
+ if (m.productMessage) return 'product';
194
+ if (m.interactiveResponseMessage) return 'native_flow_response';
195
+ if (m.groupInviteMessage) return 'url';
196
+ return '';
197
+ };
126
198
 
127
- const getMessageType = (message) => {
128
- const normalizedMessage = normalizeMessageContent(message);
129
- if (!normalizedMessage) return "text";
130
- if (normalizedMessage.reactionMessage || normalizedMessage.encReactionMessage) return "reaction";
131
- if (
132
- normalizedMessage.pollCreationMessage ||
133
- normalizedMessage.pollCreationMessageV2 ||
134
- normalizedMessage.pollCreationMessageV3 ||
135
- normalizedMessage.pollUpdateMessage
136
- ) return "poll";
137
- if (normalizedMessage.eventMessage) return "event";
138
- if (getMediaType(normalizedMessage) !== "") return "media";
139
- return "text";
199
+ const stanzaTypeOf = (message) => {
200
+ const m = normalizeMessageContent(message);
201
+ if (!m) return 'text';
202
+ if (m.reactionMessage || m.encReactionMessage) return 'reaction';
203
+ if (m.pollCreationMessage || m.pollCreationMessageV2 || m.pollCreationMessageV3 ||
204
+ m.pollCreationMessageV4 || m.pollCreationMessageV5 || m.pollUpdateMessage) return 'poll';
205
+ if (m.eventMessage) return 'event';
206
+ return mediaTypeOf(m) ? 'media' : 'text';
140
207
  };
141
208
 
142
- const getMediaType = (message) => {
143
- if (message.imageMessage) return "image";
144
- if (message.videoMessage) return message.videoMessage.gifPlayback ? "gif" : "video";
145
- if (message.audioMessage) return message.audioMessage.ptt ? "ptt" : "audio";
146
- if (message.contactMessage) return "vcard";
147
- if (message.documentMessage) return "document";
148
- if (message.contactsArrayMessage) return "contact_array";
149
- if (message.liveLocationMessage) return "livelocation";
150
- if (message.stickerMessage) return "sticker";
151
- if (message.listMessage) return "list";
152
- if (message.listResponseMessage) return "list_response";
153
- if (message.buttonsResponseMessage) return "buttons_response";
154
- if (message.orderMessage) return "order";
155
- if (message.productMessage) return "product";
156
- if (message.interactiveResponseMessage) return "native_flow_response";
157
- if (message.groupInviteMessage) return "url";
158
- return "";
209
+ const decryptFailHidden = (message, hide) => {
210
+ const m = normalizeMessageContent(message);
211
+ return hide === true || !!(m?.pinInChatMessage || m?.reactionMessage);
159
212
  };
160
213
 
161
- export async function sPR(conn, jid, message, {
162
- messageId: msgId,
163
- additionalAttributes,
164
- useUserDevicesCache,
165
- useCachedGroupMetadata,
166
- statusJidList,
167
- additionalNodes,
168
- targetMe,
169
- mode = "exclude",
170
- target = [],
171
- hideDecryptFail = true,
172
- deviceMode = "all"
173
- } = {}) {
174
- const meId = conn.authState?.creds?.me?.id || conn.user?.id || "";
175
- const meLid = conn.authState?.creds?.me?.lid || conn.user?.lid || "";
176
-
177
- const getLidForPn = async (pnJid) => {
178
- try {
179
- return await conn.signalRepository?.lidMapping?.getLIDForPN(pnJid);
180
- } catch {
181
- return null;
182
- }
183
- };
214
+ /* -------------------------------------------------------------------------- */
215
+ /* Identitas (PN <-> LID) dan pencocokan target */
216
+ /* -------------------------------------------------------------------------- */
184
217
 
185
- const getPnForLid = async (lidJid) => {
186
- try {
187
- return await conn.signalRepository?.lidMapping?.getPNForLID(lidJid);
188
- } catch {
189
- return null;
190
- }
191
- };
218
+ const participantAliases = (p) =>
219
+ [p?.id, p?.jid, p?.lid, p?.phoneNumber, p?.pn].filter(Boolean).map(userOf).filter(Boolean);
192
220
 
193
- const processFilters = async (list) => {
194
- const rules = [];
195
- if (!Array.isArray(list)) return rules;
196
- for (const item of list) {
197
- if (!item) continue;
198
- const decoded = jidDecode(item);
199
- if (!decoded) continue;
200
-
201
- const hasDevice = item.includes(":");
202
- const targetDevice = hasDevice ? (decoded.device !== undefined ? decoded.device : 0) : null;
203
-
204
- rules.push({
205
- user: decoded.user,
206
- device: targetDevice,
207
- server: decoded.server
208
- });
209
-
210
- if (decoded.server === "s.whatsapp.net") {
211
- const lidJid = await getLidForPn(item);
212
- if (lidJid) {
213
- const lidDecoded = jidDecode(lidJid);
214
- if (lidDecoded) {
215
- rules.push({
216
- user: lidDecoded.user,
217
- device: targetDevice,
218
- server: lidDecoded.server
219
- });
220
- }
221
- }
222
- } else if (decoded.server === "lid") {
223
- const pnJid = await getPnForLid(item);
224
- if (pnJid) {
225
- const pnDecoded = jidDecode(pnJid);
226
- if (pnDecoded) {
227
- rules.push({
228
- user: pnDecoded.user,
229
- device: targetDevice,
230
- server: pnDecoded.server
231
- });
232
- }
233
- }
234
- }
235
- }
236
- return rules;
237
- };
221
+ /** Peta alias user -> himpunan semua alias user milik orang yang sama */
222
+ const buildAliasIndex = (groupData) => {
223
+ const index = new Map();
224
+ for (const p of groupData?.participants || []) {
225
+ const set = new Set(participantAliases(p));
226
+ for (const alias of set) index.set(alias, set);
227
+ }
228
+ return index;
229
+ };
238
230
 
239
- const parsedTargets = await processFilters(target);
231
+ const lookupMapped = async (conn, jid) => {
232
+ const mapping = conn.signalRepository?.lidMapping;
233
+ if (!mapping) return null;
234
+ const decoded = jidDecode(jid);
235
+ if (!decoded) return null;
236
+ if (decoded.server === 's.whatsapp.net') return attempt(() => mapping.getLIDForPN(jid));
237
+ if (decoded.server === 'lid') return attempt(() => mapping.getPNForLID(jid));
238
+ return null;
239
+ };
240
240
 
241
- const isMatch = (targetJid, rules) => {
242
- const decoded = jidDecode(targetJid);
243
- if (!decoded) return false;
241
+ /** Ubah daftar JID menjadi aturan {user, device|null} lengkap dengan alias PN/LID */
242
+ const buildRules = async (conn, list, aliasIndex) => {
243
+ const rules = [];
244
+ for (const raw of Array.isArray(list) ? list : []) {
245
+ const decoded = raw ? jidDecode(raw) : null;
246
+ if (!decoded) continue;
247
+ const device = String(raw).includes(':') ? (decoded.device ?? 0) : null;
248
+ const users = new Set([decoded.user, ...(aliasIndex.get(decoded.user) || [])]);
249
+ const mapped = await lookupMapped(conn, raw);
250
+ if (mapped) users.add(userOf(mapped));
251
+ for (const user of users) rules.push({ user, device });
252
+ }
253
+ return rules;
254
+ };
244
255
 
245
- const targetDevice = decoded.device !== undefined ? decoded.device : 0;
256
+ const matchesRules = (deviceJid, rules) => {
257
+ const d = jidDecode(deviceJid);
258
+ if (!d) return false;
259
+ const device = d.device ?? 0;
260
+ return rules.some((r) => r.user === d.user && (r.device === null || r.device === device));
261
+ };
246
262
 
247
- for (const rule of rules) {
248
- if (rule.user === decoded.user) {
249
- if (rule.device === null) return true;
250
- if (rule.device === targetDevice) return true;
251
- }
252
- }
253
- return false;
254
- };
263
+ /**
264
+ * Normalisasi opsi `personal` menjadi fungsi (deviceUser) -> message | null.
265
+ * Bentuk yang diterima: function({jid,user}), Map, atau object {jid: message}.
266
+ */
267
+ const buildPersonalResolver = async (conn, personal, aliasIndex) => {
268
+ if (!personal) return () => null;
269
+ if (typeof personal === 'function') {
270
+ const memo = new Map();
271
+ return (ctx) => {
272
+ if (!memo.has(ctx.user)) memo.set(ctx.user, personal(ctx) || null);
273
+ return memo.get(ctx.user);
274
+ };
275
+ }
276
+ const entries = personal instanceof Map ? [...personal.entries()] : Object.entries(personal);
277
+ const byUser = new Map();
278
+ for (const [jid, message] of entries) {
279
+ if (!message) continue;
280
+ const users = new Set([userOf(jid), ...(aliasIndex.get(userOf(jid)) || [])]);
281
+ const mapped = await lookupMapped(conn, jid);
282
+ if (mapped) users.add(userOf(mapped));
283
+ for (const user of users) if (user) byUser.set(user, message);
284
+ }
285
+ return (ctx) => byUser.get(ctx.user) || null;
286
+ };
287
+
288
+ /* -------------------------------------------------------------------------- */
289
+ /* Inti: sPR */
290
+ /* -------------------------------------------------------------------------- */
291
+
292
+ const isHostedDevice = (device) =>
293
+ isHostedLidUser(device.jid) || isHostedPnUser(device.jid) || device.device === 99;
294
+
295
+ const runEncrypt = (conn, groups, extraFor) =>
296
+ runPool(groups, SPR_LIMITS.encryptConcurrency, async ({ jids, message, dsm }) => {
297
+ const extra = extraFor(message);
298
+ return conn.createParticipantNodes(jids, message, extra, dsm);
299
+ });
300
+
301
+ /**
302
+ * Kirim pesan dengan kontrol audiens per device.
303
+ *
304
+ * @param conn socket sauruspro-bail
305
+ * @param jid grup / chat / status@broadcast
306
+ * @param message pesan publik (boleh null jika semua penerima punya pesan personal)
307
+ * @param options
308
+ * mode 'exclude' (default) | 'include' -> berlaku untuk pesan publik
309
+ * target JID yang dikenai mode
310
+ * targetMe true/false memaksa device milik sendiri menerima/tidak
311
+ * deviceMode 'all' | 'primary' | 'secondary'
312
+ * personal function({jid,user}) | Map | {jid: message} -> pesan khusus per orang.
313
+ * Penerima personal TIDAK menerima pesan publik.
314
+ * hideDecryptFail sembunyikan placeholder gagal-dekripsi (default true)
315
+ * messageId, additionalAttributes, additionalNodes, useUserDevicesCache,
316
+ * useCachedGroupMetadata, statusJidList
317
+ * @returns messageId
318
+ */
319
+ export async function sPR(conn, jid, message, options = {}) {
320
+ let {
321
+ messageId: msgId,
322
+ additionalAttributes,
323
+ additionalNodes,
324
+ useUserDevicesCache,
325
+ useCachedGroupMetadata,
326
+ statusJidList,
327
+ targetMe,
328
+ mode = 'exclude',
329
+ target = [],
330
+ personal,
331
+ hideDecryptFail = true,
332
+ deviceMode = 'all'
333
+ } = options;
334
+
335
+ if (!jid) throw new Boom('sPR: jid wajib diisi', { statusCode: 400 });
336
+ if (!message && !personal) throw new Boom('sPR: message atau personal wajib diisi', { statusCode: 400 });
337
+
338
+ const creds = conn.authState?.creds;
339
+ const meId = creds?.me?.id || conn.user?.id || '';
340
+ const meLid = creds?.me?.lid || conn.user?.lid || '';
341
+ const meUsers = new Set([userOf(meId), userOf(meLid)].filter(Boolean));
342
+ const isSelfDevice = (deviceJid) => meUsers.has(userOf(deviceJid));
343
+ const isSenderDevice = (deviceJid) => deviceJid === meId || (!!meLid && deviceJid === meLid);
344
+
345
+ const statusJid = 'status@broadcast';
346
+ const { server } = jidDecode(jid);
347
+
348
+ // Newsletter & pesan peer tidak punya konsep audiens: serahkan ke relay biasa.
349
+ if (server === 'newsletter' || additionalAttributes?.category === 'peer') {
350
+ return conn.relayMessage(jid, message, { messageId: msgId, additionalAttributes, additionalNodes });
351
+ }
255
352
 
256
- let shouldIncludeDeviceIdentity = false;
257
- const statusJid = "status@broadcast";
258
- const { user, server } = jidDecode(jid);
259
- const isNewsletter = server === "newsletter";
260
- const isGroup = server === "g.us";
353
+ const isGroup = isJidGroup(jid);
261
354
  const isStatus = jid === statusJid;
262
355
  const isGroupOrStatus = isGroup || isStatus;
356
+ const destinationJid = isStatus ? statusJid : jid;
357
+
263
358
  msgId = msgId || generateMessageIDV2(meId);
264
359
  useUserDevicesCache = useUserDevicesCache !== false;
265
360
  useCachedGroupMetadata = useCachedGroupMetadata !== false && !isStatus;
266
- const participants = [];
267
- const destinationJid = !isStatus ? jid : statusJid;
268
- const binaryNodeContent = [];
269
- const devices = [];
270
-
271
- const reportingToken = !isNewsletter && !isStatus ? deriveReportingTag(message, msgId, destinationJid) : null;
272
-
273
- const meMsg = { deviceSentMessage: { destinationJid, message }, messageContextInfo: message.messageContextInfo };
274
- const extraAttrs = {};
275
- const messages = normalizeMessageContent(message);
276
-
277
- if (isNewsletter) {
278
- const patched = conn.patchMessageBeforeSending ? await conn.patchMessageBeforeSending(message, []) : message;
279
- const bytes = encodeNewsletterMessage(patched);
280
- binaryNodeContent.push({ tag: "plaintext", attrs: {}, content: bytes });
281
- const stanza = {
282
- tag: "message",
283
- attrs: {
284
- to: jid,
285
- id: msgId,
286
- type: getMessageType(message),
287
- ...(additionalAttributes || {})
288
- },
289
- content: binaryNodeContent
290
- };
291
- await conn.sendNode(stanza);
292
- return msgId;
293
- }
294
361
 
295
- if (hideDecryptFail === true) {
296
- extraAttrs["decrypt-fail"] = "hide";
362
+ const baseExtra = {};
363
+ if (decryptFailHidden(message, hideDecryptFail)) baseExtra['decrypt-fail'] = 'hide';
364
+ const publicMedia = mediaTypeOf(normalizeMessageContent(message));
365
+ if (publicMedia) baseExtra.mediatype = publicMedia;
366
+
367
+ const extraFor = (msg) => {
368
+ const media = mediaTypeOf(normalizeMessageContent(msg));
369
+ const extra = { ...baseExtra };
370
+ if (media) extra.mediatype = media;
371
+ else delete extra.mediatype;
372
+ return extra;
373
+ };
374
+
375
+ /* ---------- kumpulkan device & grup ---------- */
376
+ let groupData;
377
+ let devices = [];
378
+ if (isGroupOrStatus) {
379
+ groupData = useCachedGroupMetadata && conn.cachedGroupMetadata ? await conn.cachedGroupMetadata(jid) : undefined;
380
+ if (!(groupData && Array.isArray(groupData.participants)) && !isStatus) groupData = await conn.groupMetadata(jid);
381
+ const ids = groupData ? groupData.participants.map((p) => p.id) : [];
382
+ if (isStatus && statusJidList) ids.push(...statusJidList);
383
+ if (groupData?.ephemeralDuration > 0) {
384
+ additionalAttributes = { ...additionalAttributes, expiration: String(groupData.ephemeralDuration) };
385
+ }
386
+ devices = await conn.getUSyncDevices(ids, !!useUserDevicesCache, false);
387
+ if (isGroup) {
388
+ additionalAttributes = { ...additionalAttributes, addressing_mode: groupData?.addressingMode || 'lid' };
389
+ }
390
+ } else {
391
+ const lidChat = isLidUser(jid) && meLid;
392
+ const self = jidEncode(userOf(lidChat ? meLid : meId), lidChat ? 'lid' : 's.whatsapp.net');
393
+ devices = await conn.getUSyncDevices([self, jid], true, false);
297
394
  }
298
395
 
299
- const mediaType = getMediaType(messages);
300
- if (mediaType) extraAttrs["mediatype"] = mediaType;
396
+ const aliasIndex = buildAliasIndex(groupData);
397
+ const rules = await buildRules(conn, target, aliasIndex);
398
+ const personalFor = await buildPersonalResolver(conn, personal, aliasIndex);
399
+
400
+ /* ---------- klasifikasi tiap device ---------- */
401
+ const publicDevices = [];
402
+ const personalByUser = new Map(); // user -> { message, jids[] }
403
+
404
+ for (const device of devices) {
405
+ const deviceJid = device.jid;
406
+ if (isSenderDevice(deviceJid)) continue;
407
+
408
+ const d = jidDecode(deviceJid);
409
+ const deviceId = d?.device ?? 0;
410
+ if (deviceMode === 'primary' && deviceId !== 0) continue;
411
+ if (deviceMode === 'secondary' && deviceId === 0) continue;
412
+
413
+ const mine = isSelfDevice(deviceJid);
414
+ const own = mine && typeof targetMe === 'boolean' ? targetMe : null;
415
+ if (own === false) continue;
416
+
417
+ const personalMsg = own === null ? personalFor({ jid: deviceJid, user: d?.user }) : null;
418
+ if (personalMsg) {
419
+ const slot = personalByUser.get(d.user) || { message: personalMsg, jids: [] };
420
+ slot.jids.push(deviceJid);
421
+ personalByUser.set(d.user, slot);
422
+ continue;
423
+ }
301
424
 
302
- if (messages?.pinInChatMessage || messages?.reactionMessage) {
303
- extraAttrs["decrypt-fail"] = "hide";
425
+ if (!message) continue;
426
+ let allowed = mode === 'include' ? matchesRules(deviceJid, rules) : !matchesRules(deviceJid, rules);
427
+ if (own !== null) allowed = own;
428
+ if (allowed) publicDevices.push(deviceJid);
304
429
  }
305
430
 
431
+ const publicRecipients = isGroupOrStatus
432
+ ? publicDevices.filter((j) => !isHostedLidUser(j) && !isHostedPnUser(j))
433
+ : publicDevices;
434
+ const personalGroups = [...personalByUser.values()].map((slot) => ({
435
+ jids: slot.jids,
436
+ message: slot.message
437
+ }));
438
+ const personalRecipients = personalGroups.flatMap((g) => g.jids);
439
+
440
+ await conn.assertSessions([...publicRecipients, ...personalRecipients]);
441
+
442
+ /* ---------- enkripsi ---------- */
443
+ const participantNodes = [];
444
+ const content = [];
445
+ let needsIdentity = false;
446
+ let reportingSource = message;
447
+
306
448
  if (isGroupOrStatus) {
307
- let groupData = useCachedGroupMetadata && conn.cachedGroupMetadata ? await conn.cachedGroupMetadata(jid) : undefined;
308
- if (!(groupData && Array.isArray(groupData?.participants)) && !isStatus) groupData = await conn.groupMetadata(jid);
309
-
310
- if (groupData?.participants && Array.isArray(target)) {
311
- for (const p of groupData.participants) {
312
- const pId = p.id || p.jid;
313
- const pLid = p.lid;
314
- const pPn = p.phoneNumber || p.pn;
315
- for (const item of target) {
316
- if (!item) continue;
317
- const decItem = jidDecode(item);
318
- if (!decItem) continue;
319
- const hasDevice = item.includes(":");
320
- const targetDevice = hasDevice ? (decItem.device !== undefined ? decItem.device : 0) : null;
321
- if (
322
- decItem.user === jidDecode(pId)?.user ||
323
- (pLid && decItem.user === jidDecode(pLid)?.user) ||
324
- (pPn && decItem.user === jidDecode(pPn)?.user)
325
- ) {
326
- for (const candidate of [pId, pLid, pPn]) {
327
- const decCand = candidate ? jidDecode(candidate) : null;
328
- if (decCand) {
329
- parsedTargets.push({
330
- user: decCand.user,
331
- device: targetDevice,
332
- server: decCand.server
333
- });
334
- }
335
- }
449
+ if (message) {
450
+ const patched = typeof conn.patchMessageBeforeSending === 'function'
451
+ ? await conn.patchMessageBeforeSending(message)
452
+ : message;
453
+ if (Array.isArray(patched)) throw new Boom('Per-jid patching is not supported in groups');
454
+ reportingSource = patched;
455
+ const senderKey = createOneShotSenderKey();
456
+ const skmsg = senderKey.seal(encodeWAMessage(patched));
457
+
458
+ if (publicRecipients.length) {
459
+ const skdm = {
460
+ senderKeyDistributionMessage: {
461
+ groupId: destinationJid,
462
+ axolotlSenderKeyDistributionMessage: senderKey.distribution()
336
463
  }
337
- }
464
+ };
465
+ const res = await conn.createParticipantNodes(publicRecipients, skdm, baseExtra);
466
+ participantNodes.push(...res.nodes);
467
+ needsIdentity ||= res.shouldIncludeDeviceIdentity;
338
468
  }
469
+ content.push({ tag: 'enc', attrs: { v: '2', type: 'skmsg', ...baseExtra }, content: skmsg });
339
470
  }
340
471
 
341
- const participantsList = groupData ? groupData.participants.map((p) => p.id) : [];
342
- if (groupData?.ephemeralDuration && groupData.ephemeralDuration > 0) {
343
- additionalAttributes = { ...additionalAttributes, expiration: groupData.ephemeralDuration.toString() };
472
+ const results = await runEncrypt(conn, personalGroups, extraFor);
473
+ for (const res of results) {
474
+ if (!res) continue;
475
+ participantNodes.push(...res.nodes);
476
+ needsIdentity ||= res.shouldIncludeDeviceIdentity;
344
477
  }
345
- if (isStatus && statusJidList) participantsList.push(...statusJidList);
346
- devices.push(...(await conn.getUSyncDevices(participantsList, !!useUserDevicesCache, false)));
347
- if (isGroup) additionalAttributes = { ...additionalAttributes, addressing_mode: groupData?.addressingMode || "lid" };
348
-
349
- const patched = conn.patchMessageBeforeSending ? await conn.patchMessageBeforeSending(message) : message;
350
- if (Array.isArray(patched)) throw new Boom("Per-jid patching is not supported in groups");
351
- const bytes = encodeWAMessage(patched);
352
-
353
- const chainSeed = crypto.randomBytes(32);
354
- const { privateKey: signingKey, publicKey: signingPubKey } = generateSigningKeyPair();
355
- const keyId = crypto.randomBytes(4).readUInt32BE(0) & 0x7FFFFFFF;
356
-
357
- const skmsg = encryptGroupMessage(bytes, chainSeed, signingKey, keyId);
358
- const skdm = createSKDM(keyId, 0, chainSeed, signingPubKey);
359
-
360
- const senderKeyRecipients = [];
361
- const mePnUserDec = jidDecode(meId);
362
- const meLidUserDec = meLid ? jidDecode(meLid) : null;
363
-
364
- for (const device of devices) {
365
- const deviceJid = device.jid;
366
- const isOwnDevice = (jidDecode(deviceJid)?.user === mePnUserDec?.user) || (meLidUserDec && jidDecode(deviceJid)?.user === meLidUserDec?.user);
367
-
368
- let shouldSendRealKey = true;
369
- if (mode === "include") {
370
- shouldSendRealKey = isMatch(deviceJid, parsedTargets);
371
- } else {
372
- shouldSendRealKey = !isMatch(deviceJid, parsedTargets);
373
- }
478
+ if (personalGroups.length && !results.some((r) => r?.nodes?.length) && !publicRecipients.length) {
479
+ throw new Boom(SPR_TEXT.encryptFailed, { statusCode: 500 });
480
+ }
481
+ } else {
482
+ const meDsm = (msg) => ({
483
+ deviceSentMessage: { destinationJid, message: msg },
484
+ messageContextInfo: msg?.messageContextInfo
485
+ });
486
+ const groups = [];
487
+ if (message && publicRecipients.length) {
488
+ groups.push({ jids: publicRecipients, message, dsm: meDsm(message) });
489
+ }
490
+ for (const g of personalGroups) groups.push({ ...g, dsm: meDsm(g.message) });
374
491
 
375
- if (isOwnDevice && typeof targetMe === "boolean") {
376
- shouldSendRealKey = targetMe;
377
- }
492
+ const results = await runEncrypt(conn, groups, extraFor);
493
+ for (const res of results) {
494
+ if (!res) continue;
495
+ participantNodes.push(...res.nodes);
496
+ needsIdentity ||= res.shouldIncludeDeviceIdentity;
497
+ }
498
+ if (groups.length && !participantNodes.length) {
499
+ throw new Boom(SPR_TEXT.encryptFailed, { statusCode: 500 });
500
+ }
501
+ if (participantNodes.length) {
502
+ additionalAttributes = {
503
+ ...additionalAttributes,
504
+ phash: generateParticipantHashV2([...publicRecipients, ...personalRecipients])
505
+ };
506
+ }
507
+ }
378
508
 
379
- const decDev = jidDecode(deviceJid);
380
- const dId = decDev?.device !== undefined ? decDev.device : 0;
381
- if (deviceMode === "primary" && dId !== 0) shouldSendRealKey = false;
382
- if (deviceMode === "secondary" && dId === 0) shouldSendRealKey = false;
509
+ if (!participantNodes.length && !content.length) {
510
+ throw new Boom('sPR: tidak ada penerima yang cocok', { statusCode: 400 });
511
+ }
383
512
 
384
- if (shouldSendRealKey && !isHostedLidUser(deviceJid) && !isHostedPnUser(deviceJid) && device.device !== 99) {
385
- senderKeyRecipients.push(deviceJid);
386
- }
387
- }
513
+ if (participantNodes.length) content.unshift({ tag: 'participants', attrs: {}, content: participantNodes });
388
514
 
389
- const groupParticipantsNodes = [];
515
+ /* ---------- susun stanza ---------- */
516
+ const typeSource = message || personalGroups[0]?.message;
517
+ const stanza = {
518
+ tag: 'message',
519
+ attrs: { id: msgId, to: destinationJid, type: stanzaTypeOf(typeSource), ...(additionalAttributes || {}) },
520
+ content
521
+ };
390
522
 
391
- if (senderKeyRecipients.length > 0) {
392
- await conn.assertSessions(senderKeyRecipients);
523
+ if (needsIdentity && creds?.account) {
524
+ stanza.content.push({ tag: 'device-identity', attrs: {}, content: encodeSignedDeviceIdentity(creds.account, true) });
525
+ }
393
526
 
394
- const senderKeyMsg = {
395
- senderKeyDistributionMessage: {
396
- groupId: destinationJid,
397
- axolotlSenderKeyDistributionMessage: skdm
398
- }
399
- };
400
- const { nodes: skdmNodes, shouldIncludeDeviceIdentity: s1 } = await conn.createParticipantNodes(senderKeyRecipients, senderKeyMsg);
401
- shouldIncludeDeviceIdentity = shouldIncludeDeviceIdentity || s1;
402
- groupParticipantsNodes.push(...skdmNodes);
403
- }
527
+ const hasReportingNode = additionalNodes?.some((n) => n.tag === 'reporting');
528
+ if (message && reportingSource?.messageContextInfo?.messageSecret && shouldIncludeReportingToken(reportingSource) && !hasReportingNode) {
529
+ const node = await attempt(() =>
530
+ getMessageReportingToken(encodeWAMessage(reportingSource), reportingSource, {
531
+ id: msgId,
532
+ fromMe: true,
533
+ remoteJid: destinationJid
534
+ })
535
+ );
536
+ if (node) stanza.content.push(node);
537
+ }
404
538
 
405
- if (groupParticipantsNodes.length > 0) {
406
- binaryNodeContent.push({ tag: "participants", attrs: {}, content: groupParticipantsNodes });
539
+ if (!isGroupOrStatus) {
540
+ const tcJid = await attempt(() => resolveTcTokenJid(destinationJid, (pn) => conn.signalRepository?.lidMapping?.getLIDForPN(pn)), destinationJid);
541
+ const entry = (await attempt(() => conn.authState.keys.get('tctoken', [tcJid]), {}))?.[tcJid];
542
+ if (entry?.token?.length && !isTcTokenExpired(entry.timestamp)) {
543
+ stanza.content.push({ tag: 'tctoken', attrs: {}, content: entry.token });
407
544
  }
545
+ }
408
546
 
409
- binaryNodeContent.push({ tag: "enc", attrs: { v: "2", type: "skmsg", ...extraAttrs }, content: skmsg });
410
- } else {
411
- let ownId = isLidUser(jid) && meLid ? meLid : meId;
412
- const { user: ownUser } = jidDecode(ownId);
413
- const targetUserServer = isLidUser(jid) ? "lid" : "s.whatsapp.net";
414
- devices.push({ user, device: 0, jid: jidEncode(user, targetUserServer, 0) });
415
- if (user !== ownUser) {
416
- const ownUserServer = isLidUser(jid) ? "lid" : "s.whatsapp.net";
417
- const ownUserForAddressing = isLidUser(jid) && meLid ? jidDecode(meLid).user : jidDecode(meId).user;
418
- devices.push({ user: ownUserForAddressing, device: 0, jid: jidEncode(ownUserForAddressing, ownUserServer, 0) });
419
- }
420
- if (additionalAttributes?.["category"] !== "peer") {
421
- devices.length = 0;
422
- const senderIdentity = isLidUser(jid) && meLid ? jidEncode(jidDecode(meLid)?.user, "lid", undefined) : jidEncode(jidDecode(meId)?.user, "s.whatsapp.net", undefined);
423
- devices.push(...(await conn.getUSyncDevices([senderIdentity, jid], true, false)));
424
- }
547
+ if (additionalNodes?.length) stanza.content.push(...additionalNodes);
425
548
 
426
- const allRecipients = [];
427
- const meRecipients = [];
428
- const otherRecipients = [];
429
- const mePnUserDec = jidDecode(meId);
430
- const meLidUserDec = meLid ? jidDecode(meLid) : null;
431
-
432
- for (const device of devices) {
433
- const deviceJid = device.jid;
434
- const isExactSenderDevice = deviceJid === meId || (meLid && deviceJid === meLid);
435
- if (isExactSenderDevice) continue;
436
-
437
- let shouldSend = true;
438
- if (mode === "include") {
439
- shouldSend = isMatch(deviceJid, parsedTargets);
440
- } else {
441
- shouldSend = !isMatch(deviceJid, parsedTargets);
442
- }
549
+ await conn.sendNode(stanza);
550
+ return msgId;
551
+ }
443
552
 
444
- const isMe = (device.user === mePnUserDec?.user) || (meLidUserDec && device.user === meLidUserDec?.user);
553
+ export const selectiveRelay = sPR;
445
554
 
446
- if (isMe && typeof targetMe === "boolean") {
447
- shouldSend = targetMe;
448
- }
555
+ /* -------------------------------------------------------------------------- */
556
+ /* Anggota grup (adaptasi readMembers zapo) */
557
+ /* -------------------------------------------------------------------------- */
449
558
 
450
- const decDev = jidDecode(deviceJid);
451
- const dId = decDev?.device !== undefined ? decDev.device : 0;
452
- if (deviceMode === "primary" && dId !== 0) shouldSend = false;
453
- if (deviceMode === "secondary" && dId === 0) shouldSend = false;
559
+ /**
560
+ * Baca anggota grup yang layak jadi target: bot tidak dihitung, tanpa duplikat,
561
+ * admin didahulukan, dipotong sesuai `limit`.
562
+ */
563
+ export async function getSPRTargets(conn, groupJid, { limit = SPR_LIMITS.targets, exclude = [] } = {}) {
564
+ const meta = await conn.groupMetadata(groupJid);
565
+ const creds = conn.authState?.creds;
566
+ const skip = new Set(
567
+ [creds?.me?.id, creds?.me?.lid, conn.user?.id, conn.user?.lid, ...exclude].filter(Boolean).map(userOf)
568
+ );
569
+
570
+ const seen = new Set();
571
+ const members = [];
572
+ for (const p of meta?.participants || []) {
573
+ const aliases = participantAliases(p);
574
+ if (!aliases.length || aliases.some((a) => skip.has(a) || seen.has(a))) continue;
575
+ aliases.forEach((a) => seen.add(a));
576
+ members.push(p);
577
+ }
454
578
 
455
- if (!shouldSend) continue;
579
+ const rank = (p) => (p.admin === 'superadmin' ? 0 : p.admin ? 1 : 2);
580
+ const ordered = [...members].sort((a, b) => rank(a) - rank(b));
581
+ const targets = ordered.slice(0, Math.max(0, limit));
582
+ return { meta, targets, total: members.length, overflow: Math.max(0, members.length - targets.length) };
583
+ }
456
584
 
457
- if (isMe) {
458
- meRecipients.push(deviceJid);
459
- allRecipients.push(deviceJid);
460
- } else {
461
- otherRecipients.push(deviceJid);
462
- allRecipients.push(deviceJid);
463
- }
464
- }
585
+ /* -------------------------------------------------------------------------- */
586
+ /* Foto profil per penerima (adaptasi avatar loader zapo) */
587
+ /* -------------------------------------------------------------------------- */
588
+
589
+ const fetchAvatarBytes = async (url, timeoutMs) => {
590
+ if (!url) return null;
591
+ if (avatarBytesCache.has(url)) return avatarBytesCache.get(url);
592
+ return attempt(async () => {
593
+ const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs), headers: { 'User-Agent': 'Mozilla/5.0' } });
594
+ if (!res.ok) return null;
595
+ const bytes = Buffer.from(await res.arrayBuffer());
596
+ return bytes.length ? avatarBytesCache.set(url, bytes) : null;
597
+ });
598
+ };
465
599
 
466
- await conn.assertSessions(allRecipients);
467
- const [{ nodes: meNodes, shouldIncludeDeviceIdentity: s1 }, { nodes: otherNodes, shouldIncludeDeviceIdentity: s2 }] = await Promise.all([
468
- conn.createParticipantNodes(meRecipients, meMsg || message, extraAttrs),
469
- conn.createParticipantNodes(otherRecipients, message, extraAttrs, meMsg)
470
- ]);
471
- participants.push(...meNodes, ...otherNodes);
472
- if (meRecipients.length || otherRecipients.length) extraAttrs["phash"] = generateParticipantHashV2([...meRecipients, ...otherRecipients]);
473
- shouldIncludeDeviceIdentity = shouldIncludeDeviceIdentity || s1 || s2;
474
- }
600
+ const uploadAvatar = async (conn, bytes) => {
601
+ if (!bytes?.length || typeof conn.waUploadToServer !== 'function') return null;
602
+ const hash = crypto.createHash('md5').update(bytes).digest('hex');
603
+ if (avatarMediaCache.has(hash)) return avatarMediaCache.get(hash);
604
+ return attempt(async () => {
605
+ const prepared = await prepareWAMessageMedia({ image: bytes }, { upload: conn.waUploadToServer, logger: conn.logger });
606
+ return prepared?.imageMessage ? avatarMediaCache.set(hash, prepared.imageMessage) : null;
607
+ });
608
+ };
475
609
 
476
- if (participants.length) {
477
- if (additionalAttributes?.["category"] === "peer") {
478
- const peerNode = participants[0]?.content?.[0];
479
- if (peerNode) binaryNodeContent.push(peerNode);
480
- } else {
481
- binaryNodeContent.push({ tag: "participants", attrs: {}, content: participants });
482
- }
610
+ const avatarUrlOf = async (conn, person) => {
611
+ const candidates = [person.id, person.phoneNumber, person.pn, person.lid, person.jid].filter(Boolean);
612
+ for (const candidate of [...new Set(candidates)]) {
613
+ const url = await attempt(() => conn.profilePictureUrl(candidate, 'image'));
614
+ if (url) return url;
483
615
  }
616
+ return null;
617
+ };
484
618
 
485
- const stanza = {
486
- tag: "message",
487
- attrs: { id: msgId, to: destinationJid, type: getMessageType(message), ...(additionalAttributes || {}) },
488
- content: binaryNodeContent
619
+ /**
620
+ * Ambil foto profil beberapa orang sekaligus (konkuren, ada budget & hard cap).
621
+ * @returns Map<user, imageMessage> (kunci: semua alias user orang tsb)
622
+ */
623
+ export async function loadSPRAvatars(conn, people, {
624
+ concurrency = SPR_LIMITS.concurrency,
625
+ budgetMs = SPR_LIMITS.avatarBudgetMs,
626
+ hardCapMs = SPR_LIMITS.avatarHardCapMs,
627
+ fetchMs = SPR_LIMITS.avatarFetchMs
628
+ } = {}) {
629
+ const map = new Map();
630
+ if (typeof conn.profilePictureUrl !== 'function') return map;
631
+ const started = Date.now();
632
+
633
+ const job = async () => {
634
+ const rows = await runPool(people, concurrency, async (person) => {
635
+ if (Date.now() - started > budgetMs) return null;
636
+ const url = await avatarUrlOf(conn, person);
637
+ const image = await uploadAvatar(conn, await fetchAvatarBytes(url, fetchMs));
638
+ return image ? { keys: participantAliases(person), image } : null;
639
+ });
640
+ for (const row of rows) {
641
+ if (row) for (const key of row.keys) map.set(key, row.image);
642
+ }
643
+ return map;
489
644
  };
490
645
 
491
- if (shouldIncludeDeviceIdentity) {
492
- stanza.content.push({ tag: "device-identity", attrs: {}, content: encodeSignedDeviceIdentity(conn.authState.creds.account, true) });
493
- }
646
+ await withTimeout(attempt(job), hardCapMs, null);
647
+ return map;
648
+ };
494
649
 
495
- if (reportingToken && !(additionalNodes && additionalNodes.some((n) => n.tag === "reporting"))) {
496
- stanza.content.push({
497
- tag: "reporting",
498
- attrs: {},
499
- content: [
500
- {
501
- tag: "reporting_token",
502
- attrs: { v: "2" },
503
- content: reportingToken
504
- }
505
- ]
506
- });
650
+ /* -------------------------------------------------------------------------- */
651
+ /* Pesan personal (gambar profil + caption + mention) */
652
+ /* -------------------------------------------------------------------------- */
653
+
654
+ const personalMessage = (image, caption, mentions) => {
655
+ const contextInfo = { mentionedJid: mentions };
656
+ return image
657
+ ? { imageMessage: { ...image, caption, contextInfo } }
658
+ : { extendedTextMessage: { text: caption, contextInfo } };
659
+ };
660
+
661
+ /* -------------------------------------------------------------------------- */
662
+ /* sPRPrivate (setara `!spr publik | privat` di plugin zapo) */
663
+ /* -------------------------------------------------------------------------- */
664
+
665
+ /**
666
+ * Anggota grup lain melihat `publicText`, `targetJid` melihat `privateText`
667
+ * (dengan foto profilnya sendiri bila `avatar` aktif).
668
+ */
669
+ export async function sPRPrivate(conn, groupJid, targetJid, {
670
+ publicText = SPR_TEXT.publicDefault,
671
+ privateText,
672
+ avatar = true,
673
+ mentionTarget = false,
674
+ ...relayOptions
675
+ } = {}) {
676
+ if (!isJidGroup(groupJid)) throw new Boom(SPR_TEXT.needGroup, { statusCode: 400 });
677
+ if (!targetJid) throw new Boom('sPRPrivate: targetJid wajib diisi', { statusCode: 400 });
678
+ if (!privateText?.trim()) throw new Boom(SPR_TEXT.emptyPrivate, { statusCode: 400 });
679
+
680
+ let image = null;
681
+ if (avatar) {
682
+ const avatars = await loadSPRAvatars(conn, [{ id: targetJid }]);
683
+ image = avatars.get(userOf(targetJid)) || null;
507
684
  }
685
+ const mentions = mentionTarget ? [targetJid] : [];
508
686
 
509
- const contactTcTokenData = !isGroup && !isStatus ? await conn.authState.keys.get("tctoken", [destinationJid]) : {};
510
- const tcTokenBuffer = contactTcTokenData[destinationJid]?.token;
511
- if (tcTokenBuffer) stanza.content.push({ tag: "tctoken", attrs: {}, content: tcTokenBuffer });
687
+ const id = await sPR(conn, groupJid, { extendedTextMessage: { text: publicText, contextInfo: { mentionedJid: [] } } }, {
688
+ ...relayOptions,
689
+ personal: new Map([[targetJid, personalMessage(image, privateText, mentions)]])
690
+ });
691
+ return { id, avatar: !!image };
692
+ }
512
693
 
513
- if (additionalNodes && additionalNodes.length > 0) stanza.content.push(...additionalNodes);
694
+ /* -------------------------------------------------------------------------- */
695
+ /* sPRBroadcast (setara `!sbpr` / `!sbrp`) */
696
+ /* -------------------------------------------------------------------------- */
514
697
 
515
- await conn.sendNode(stanza);
698
+ /**
699
+ * Kirim pesan yang dipersonalisasi ke tiap anggota: `{name}` jadi @mention
700
+ * orang itu, lengkap dengan foto profilnya. Anggota di luar batas `limit`
701
+ * melihat `publicText`.
702
+ */
703
+ export async function sPRBroadcast(conn, groupJid, {
704
+ template = SPR_TEXT.templateDefault,
705
+ publicText,
706
+ limit = SPR_LIMITS.targets,
707
+ exclude = [],
708
+ avatar = true,
709
+ ...relayOptions
710
+ } = {}) {
711
+ if (!isJidGroup(groupJid)) throw new Boom(SPR_TEXT.needGroup, { statusCode: 400 });
712
+ const info = await getSPRTargets(conn, groupJid, { limit, exclude });
713
+ if (!info.targets.length) throw new Boom(SPR_TEXT.noTarget, { statusCode: 400 });
714
+
715
+ const text = template || SPR_TEXT.templateDefault;
716
+ const hasSlot = text.includes('{name}');
717
+ const avatars = avatar ? await loadSPRAvatars(conn, info.targets) : new Map();
718
+
719
+ const personal = new Map();
720
+ let withAvatar = 0;
721
+ for (const person of info.targets) {
722
+ const id = person.id || person.jid;
723
+ const anchor = `@${userOf(id)}`;
724
+ const caption = hasSlot ? text.replaceAll('{name}', anchor) : `${anchor} ${text}`;
725
+ const image = avatars.get(userOf(id)) || null;
726
+ if (image) withAvatar++;
727
+ personal.set(id, personalMessage(image, caption, [id]));
728
+ }
516
729
 
517
- return msgId;
730
+ const pub = publicText || text.replaceAll('{name}', 'semua');
731
+ const id = await sPR(conn, groupJid, { extendedTextMessage: { text: pub, contextInfo: { mentionedJid: [] } } }, {
732
+ ...relayOptions,
733
+ personal
734
+ });
735
+
736
+ return { id, targets: info.targets.length, total: info.total, overflow: info.overflow, withAvatar };
518
737
  }
519
738
 
520
- export const selectiveRelay = sPR;
521
739
  export default sPR;