sauruspro-bail 5.0.0 → 5.0.2-alpha.spr.test
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 +12 -9
- package/docs/CHANGELOG.md +35 -4
- package/docs/SPR.md +61 -24
- package/lib/Defaults/index.js +5 -1
- package/lib/Socket/dinonic-handler.d.ts +27 -0
- package/lib/Socket/dinonic-handler.js +882 -0
- package/lib/Socket/index.js +8 -1
- package/lib/Socket/messages-recv.js +26 -0
- package/lib/Socket/messages-send.js +25 -0
- package/lib/Utils/MessageBuilder.d.ts +215 -0
- package/lib/Utils/MessageBuilder.js +3991 -0
- package/lib/Utils/a2ui.d.ts +202 -0
- package/lib/Utils/a2ui.js +732 -0
- package/lib/Utils/airich-registry.js +3 -0
- package/lib/Utils/baileys-event-stream.js +58 -0
- package/lib/Utils/bot-signature.d.ts +22 -0
- package/lib/Utils/bot-signature.js +124 -0
- package/lib/Utils/dinonic-spr.d.ts +73 -0
- package/lib/Utils/dinonic-spr.js +739 -0
- package/lib/Utils/extras.d.ts +449 -0
- package/lib/Utils/extras.js +1377 -0
- package/lib/Utils/html-app.d.ts +39 -0
- package/lib/Utils/html-app.js +291 -0
- package/lib/Utils/index.js +14 -2
- package/lib/Utils/metaai.d.ts +438 -0
- package/lib/Utils/metaai.js +911 -0
- package/lib/Utils/modern-messages.d.ts +150 -0
- package/lib/Utils/modern-messages.js +496 -0
- package/lib/Utils/native-flow.d.ts +14 -0
- package/lib/Utils/native-flow.js +118 -0
- package/lib/Utils/newsletter-status.js +9 -53
- package/lib/Utils/payment-messages.d.ts +59 -0
- package/lib/Utils/payment-messages.js +156 -0
- package/lib/Utils/scheduled-message.d.ts +82 -0
- package/lib/Utils/scheduled-message.js +113 -0
- package/lib/Utils/status-stickers.d.ts +78 -0
- package/lib/Utils/status-stickers.js +261 -0
- package/lib/index.js +2 -2
- package/package.json +4 -4
- package/lib/Utils/dinolith-spr.d.ts +0 -26
- package/lib/Utils/dinolith-spr.js +0 -521
|
@@ -0,0 +1,739 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SPR - Selective / Private Relay for sauruspro-bail
|
|
3
|
+
*
|
|
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
|
|
8
|
+
*
|
|
9
|
+
* Fitur kirim-per-penerima (privat, broadcast {name}, foto profil per orang,
|
|
10
|
+
* batas target, cache, timeout) diadaptasi dari plugin zapo-js (MIT).
|
|
11
|
+
*
|
|
12
|
+
* Maintained by Saurus / Dinonic.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
"use strict";
|
|
16
|
+
|
|
17
|
+
import crypto from 'crypto';
|
|
18
|
+
import { Boom } from '@hapi/boom';
|
|
19
|
+
import { Curve } from './crypto.js';
|
|
20
|
+
import { encodeWAMessage, generateMessageIDV2, generateParticipantHashV2 } from './generics.js';
|
|
21
|
+
import { encodeSignedDeviceIdentity } from './validate-connection.js';
|
|
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
|
+
};
|
|
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
|
+
};
|
|
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
|
+
};
|
|
94
|
+
|
|
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
|
+
};
|
|
107
|
+
|
|
108
|
+
const avatarBytesCache = makeCache(SPR_LIMITS.cacheSize);
|
|
109
|
+
const avatarMediaCache = makeCache(SPR_LIMITS.cacheSize);
|
|
110
|
+
|
|
111
|
+
const userOf = (jid) => (jid ? jidDecode(String(jid))?.user || String(jid).split('@')[0].split(':')[0] : '');
|
|
112
|
+
|
|
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;
|
|
123
|
+
}
|
|
124
|
+
out.push(v);
|
|
125
|
+
return Buffer.from(out);
|
|
126
|
+
};
|
|
127
|
+
|
|
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]);
|
|
130
|
+
|
|
131
|
+
const SIGNAL_VERSION = Buffer.from([0x33]);
|
|
132
|
+
|
|
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
|
+
};
|
|
149
|
+
|
|
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
|
+
};
|
|
174
|
+
|
|
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
|
+
};
|
|
198
|
+
|
|
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';
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const decryptFailHidden = (message, hide) => {
|
|
210
|
+
const m = normalizeMessageContent(message);
|
|
211
|
+
return hide === true || !!(m?.pinInChatMessage || m?.reactionMessage);
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
/* -------------------------------------------------------------------------- */
|
|
215
|
+
/* Identitas (PN <-> LID) dan pencocokan target */
|
|
216
|
+
/* -------------------------------------------------------------------------- */
|
|
217
|
+
|
|
218
|
+
const participantAliases = (p) =>
|
|
219
|
+
[p?.id, p?.jid, p?.lid, p?.phoneNumber, p?.pn].filter(Boolean).map(userOf).filter(Boolean);
|
|
220
|
+
|
|
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
|
+
};
|
|
230
|
+
|
|
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
|
+
|
|
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
|
+
};
|
|
255
|
+
|
|
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
|
+
};
|
|
262
|
+
|
|
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
|
+
}
|
|
352
|
+
|
|
353
|
+
const isGroup = isJidGroup(jid);
|
|
354
|
+
const isStatus = jid === statusJid;
|
|
355
|
+
const isGroupOrStatus = isGroup || isStatus;
|
|
356
|
+
const destinationJid = isStatus ? statusJid : jid;
|
|
357
|
+
|
|
358
|
+
msgId = msgId || generateMessageIDV2(meId);
|
|
359
|
+
useUserDevicesCache = useUserDevicesCache !== false;
|
|
360
|
+
useCachedGroupMetadata = useCachedGroupMetadata !== false && !isStatus;
|
|
361
|
+
|
|
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);
|
|
394
|
+
}
|
|
395
|
+
|
|
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
|
+
}
|
|
424
|
+
|
|
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);
|
|
429
|
+
}
|
|
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
|
+
|
|
448
|
+
if (isGroupOrStatus) {
|
|
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()
|
|
463
|
+
}
|
|
464
|
+
};
|
|
465
|
+
const res = await conn.createParticipantNodes(publicRecipients, skdm, baseExtra);
|
|
466
|
+
participantNodes.push(...res.nodes);
|
|
467
|
+
needsIdentity ||= res.shouldIncludeDeviceIdentity;
|
|
468
|
+
}
|
|
469
|
+
content.push({ tag: 'enc', attrs: { v: '2', type: 'skmsg', ...baseExtra }, content: skmsg });
|
|
470
|
+
}
|
|
471
|
+
|
|
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;
|
|
477
|
+
}
|
|
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) });
|
|
491
|
+
|
|
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
|
+
}
|
|
508
|
+
|
|
509
|
+
if (!participantNodes.length && !content.length) {
|
|
510
|
+
throw new Boom('sPR: tidak ada penerima yang cocok', { statusCode: 400 });
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
if (participantNodes.length) content.unshift({ tag: 'participants', attrs: {}, content: participantNodes });
|
|
514
|
+
|
|
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
|
+
};
|
|
522
|
+
|
|
523
|
+
if (needsIdentity && creds?.account) {
|
|
524
|
+
stanza.content.push({ tag: 'device-identity', attrs: {}, content: encodeSignedDeviceIdentity(creds.account, true) });
|
|
525
|
+
}
|
|
526
|
+
|
|
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
|
+
}
|
|
538
|
+
|
|
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 });
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
if (additionalNodes?.length) stanza.content.push(...additionalNodes);
|
|
548
|
+
|
|
549
|
+
await conn.sendNode(stanza);
|
|
550
|
+
return msgId;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
export const selectiveRelay = sPR;
|
|
554
|
+
|
|
555
|
+
/* -------------------------------------------------------------------------- */
|
|
556
|
+
/* Anggota grup (adaptasi readMembers zapo) */
|
|
557
|
+
/* -------------------------------------------------------------------------- */
|
|
558
|
+
|
|
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
|
+
}
|
|
578
|
+
|
|
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
|
+
}
|
|
584
|
+
|
|
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
|
+
};
|
|
599
|
+
|
|
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
|
+
};
|
|
609
|
+
|
|
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;
|
|
615
|
+
}
|
|
616
|
+
return null;
|
|
617
|
+
};
|
|
618
|
+
|
|
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;
|
|
644
|
+
};
|
|
645
|
+
|
|
646
|
+
await withTimeout(attempt(job), hardCapMs, null);
|
|
647
|
+
return map;
|
|
648
|
+
};
|
|
649
|
+
|
|
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;
|
|
684
|
+
}
|
|
685
|
+
const mentions = mentionTarget ? [targetJid] : [];
|
|
686
|
+
|
|
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
|
+
}
|
|
693
|
+
|
|
694
|
+
/* -------------------------------------------------------------------------- */
|
|
695
|
+
/* sPRBroadcast (setara `!sbpr` / `!sbrp`) */
|
|
696
|
+
/* -------------------------------------------------------------------------- */
|
|
697
|
+
|
|
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
|
+
}
|
|
729
|
+
|
|
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 };
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
export default sPR;
|