@manybot/manybot 5.2.0 → 5.2.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.
@@ -0,0 +1,1311 @@
1
+ /**
2
+ * drivers/whatsapp/api/index.ts
3
+ *
4
+ * WhatsApp plugin context builder.
5
+ * Exports buildApi() and buildSetupApi() for the WhatsApp driver.
6
+ *
7
+ * Plugins receive a `ctx` object built by buildApi().
8
+ * All wwjs types have been replaced with Baileys equivalents.
9
+ * The ctx surface area is preserved so existing plugins stay compatible.
10
+ */
11
+ import { logger } from "#logger";
12
+ import { t, createPluginT, reloadTranslations, getCurrentLang } from "#i18n";
13
+ import { CONFIG, CONFIG_DIR } from "#config";
14
+ import { enqueue } from "#download";
15
+ import { schedule, cancelPlugin } from "#kernel/scheduler.js";
16
+ import { emptyFolder } from "#utils/file.js";
17
+ import { normalizeJid, toPresenceCapable } from "../sdk/baileysSock.js";
18
+ import { mkdirSync } from "fs";
19
+ import { readFile, writeFile } from "fs/promises";
20
+ import { readFileSync } from "fs";
21
+ import path from "path";
22
+ import { waitForSendSlot, simulateState, typingDuration, mediaDuration } from "#sendguard";
23
+ import { buildSettingsApi } from "#settingsdb";
24
+ import { downloadMediaMessage, getAggregateVotesInPollMessage, decryptPollVote, jidNormalizedUser } from "@whiskeysockets/baileys";
25
+ // ── Message body / type helpers ───────────────────────────────────────────────
26
+ function getMsgBody(msg) {
27
+ const m = msg.message;
28
+ if (!m)
29
+ return "";
30
+ return (m.conversation ??
31
+ m.extendedTextMessage?.text ??
32
+ m.imageMessage?.caption ??
33
+ m.videoMessage?.caption ??
34
+ m.documentMessage?.caption ??
35
+ m.buttonsResponseMessage?.selectedDisplayText ??
36
+ m.listResponseMessage?.title ??
37
+ "");
38
+ }
39
+ function getMsgType(msg) {
40
+ const m = msg.message;
41
+ if (!m)
42
+ return "unknown";
43
+ if (m.conversation || m.extendedTextMessage)
44
+ return "chat";
45
+ if (m.imageMessage)
46
+ return "image";
47
+ if (m.videoMessage)
48
+ return "video";
49
+ if (m.audioMessage)
50
+ return "audio";
51
+ if (m.stickerMessage)
52
+ return "sticker";
53
+ if (m.documentMessage)
54
+ return "document";
55
+ if (m.pollCreationMessage ||
56
+ m.pollCreationMessageV2 ||
57
+ m.pollCreationMessageV3)
58
+ return "poll";
59
+ return "unknown";
60
+ }
61
+ function msgHasMedia(msg) {
62
+ const m = msg.message;
63
+ return !!(m?.imageMessage || m?.videoMessage || m?.audioMessage || m?.documentMessage || m?.stickerMessage);
64
+ }
65
+ function msgIsGif(msg) {
66
+ return !!(msg.message?.videoMessage?.gifPlayback);
67
+ }
68
+ /** Sender JID — group participant or DM remote JID, normalized. */
69
+ function getMsgSender(msg, store) {
70
+ const raw = normalizeJid(msg.key.participant || msg.key.remoteJid || "");
71
+ return normalizeJid(store.resolveJid(raw));
72
+ }
73
+ function getContextInfo(msg) {
74
+ const m = msg.message;
75
+ return (m?.extendedTextMessage?.contextInfo ??
76
+ m?.imageMessage?.contextInfo ??
77
+ m?.videoMessage?.contextInfo ??
78
+ m?.audioMessage?.contextInfo ??
79
+ m?.documentMessage?.contextInfo ??
80
+ null);
81
+ }
82
+ function getMsgMimetype(msg) {
83
+ const m = msg.message;
84
+ return (m?.imageMessage?.mimetype ??
85
+ m?.videoMessage?.mimetype ??
86
+ m?.audioMessage?.mimetype ??
87
+ m?.documentMessage?.mimetype ??
88
+ m?.stickerMessage?.mimetype ??
89
+ "application/octet-stream");
90
+ }
91
+ // ── Chat adapter builder ──────────────────────────────────────────────────────
92
+ // Group subjects only land in `store.chats` once Baileys fires chats.upsert
93
+ // / chats.update for that chat — which depends on history sync and doesn't
94
+ // always happen promptly (or at all) for a group the bot just joined. When
95
+ // that hasn't happened yet, `chat.name` silently fell back to the numeric
96
+ // group ID. sock.groupMetadata() always has the real subject, straight from
97
+ // WhatsApp, so use it as a fallback — cached briefly so we're not hitting it
98
+ // on every single incoming message from the same group.
99
+ const GROUP_NAME_CACHE_TTL_MS = 5 * 60 * 1000;
100
+ const groupNameCache = new Map();
101
+ /**
102
+ * Build a WAChat adapter from a Baileys message + store.
103
+ * Exposed for use in messageHandler.ts.
104
+ *
105
+ * @param {WAProtoMsg} msg
106
+ * @param {WAStore} store
107
+ * @param {WASocket} sock
108
+ * @returns {Promise<WAChat>}
109
+ */
110
+ export async function buildChatFromMsg(msg, store, sock) {
111
+ const rawJid = msg.key.remoteJid ?? "";
112
+ const jid = normalizeJid(rawJid);
113
+ const user = jid.split("@")[0];
114
+ const isGroup = rawJid.endsWith("@g.us");
115
+ // Try to get name from store
116
+ const stored = store.chats.get(rawJid);
117
+ let name = stored?.name;
118
+ if (!name && isGroup) {
119
+ const cached = groupNameCache.get(rawJid);
120
+ if (cached && Date.now() - cached.at < GROUP_NAME_CACHE_TTL_MS) {
121
+ name = cached.name;
122
+ }
123
+ else {
124
+ try {
125
+ const meta = await sock.groupMetadata(rawJid);
126
+ if (meta?.subject) {
127
+ name = meta.subject;
128
+ groupNameCache.set(rawJid, { name: meta.subject, at: Date.now() });
129
+ }
130
+ }
131
+ catch {
132
+ // fall through to the numeric fallback below
133
+ }
134
+ }
135
+ }
136
+ return { id: { _serialized: jid, user }, name: name ?? user, isGroup };
137
+ }
138
+ // ── MIME map for file sends ───────────────────────────────────────────────────
139
+ const MIME_MAP = {
140
+ ".pdf": "application/pdf",
141
+ ".doc": "application/msword",
142
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
143
+ ".xls": "application/vnd.ms-excel",
144
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
145
+ ".zip": "application/zip",
146
+ ".mp3": "audio/mpeg",
147
+ ".mp4": "video/mp4",
148
+ ".jpg": "image/jpeg",
149
+ ".jpeg": "image/jpeg",
150
+ ".png": "image/png",
151
+ ".gif": "image/gif",
152
+ ".webp": "image/webp",
153
+ ".txt": "text/plain",
154
+ ".csv": "text/csv",
155
+ };
156
+ function mimeFromPath(filePath) {
157
+ return MIME_MAP[path.extname(filePath).toLowerCase()] ?? "application/octet-stream";
158
+ }
159
+ // ── Storage API ───────────────────────────────────────────────────────────────
160
+ export function buildStorageApi(pluginName) {
161
+ if (typeof pluginName !== "string" || pluginName.trim() === "") {
162
+ throw new Error("[storage] buildStorageApi: pluginName must be a non-empty string");
163
+ }
164
+ const dir = path.join(CONFIG_DIR, "data", pluginName);
165
+ mkdirSync(dir, { recursive: true });
166
+ return {
167
+ dir,
168
+ /**
169
+ * Resolves a path inside the plugin's data directory.
170
+ * Creates subdirectories automatically.
171
+ * @param {string} relativePath
172
+ * @returns {string}
173
+ */
174
+ resolve(relativePath) {
175
+ if (!relativePath || typeof relativePath !== "string")
176
+ throw new Error(`[storage] resolve() requires a non-empty string, got: ${typeof relativePath}`);
177
+ if (relativePath.includes(".."))
178
+ throw new Error(`[storage] path traversal detected in: "${relativePath}"`);
179
+ if (path.isAbsolute(relativePath))
180
+ throw new Error(`[storage] absolute paths are not allowed: "${relativePath}"`);
181
+ if (relativePath.includes("\\"))
182
+ throw new Error(`[storage] Windows-style paths are not allowed: "${relativePath}"`);
183
+ const resolved = path.join(dir, relativePath);
184
+ if (!resolved.startsWith(path.resolve(dir) + path.sep))
185
+ throw new Error(`[storage] resolved path escapes plugin data dir: "${resolved}"`);
186
+ mkdirSync(path.dirname(resolved), { recursive: true });
187
+ return resolved;
188
+ },
189
+ };
190
+ }
191
+ // ── Config API ────────────────────────────────────────────────────────────────
192
+ function buildConfigApi() {
193
+ return {
194
+ /**
195
+ * Get a config value with optional default.
196
+ * @param {string} key
197
+ * @param {any} [defaultValue]
198
+ */
199
+ get(key, defaultValue = null) {
200
+ return CONFIG[key] ?? defaultValue;
201
+ },
202
+ };
203
+ }
204
+ // ── i18n API ──────────────────────────────────────────────────────────────────
205
+ function buildI18nApi() {
206
+ return {
207
+ t,
208
+ /**
209
+ * Create a scoped t() for a plugin's own locale files.
210
+ * @param {string} pluginMetaUrl — pass import.meta.url from the plugin
211
+ */
212
+ createT: createPluginT,
213
+ reload: reloadTranslations,
214
+ getCurrentLang,
215
+ };
216
+ }
217
+ // ── Utils API ─────────────────────────────────────────────────────────────────
218
+ function buildUtilsApi() {
219
+ return { emptyFolder };
220
+ }
221
+ // ── Download API ──────────────────────────────────────────────────────────────
222
+ function buildDownloadApi() {
223
+ return {
224
+ /**
225
+ * Enqueue a download work function.
226
+ * @param {Function} workFn
227
+ * @param {Function} [errorFn]
228
+ */
229
+ enqueue,
230
+ };
231
+ }
232
+ // ── Scheduler API ─────────────────────────────────────────────────────────────
233
+ function buildSchedulerApi(pluginName) {
234
+ return {
235
+ /**
236
+ * Register a cron task, scoped to this plugin.
237
+ * Re-registering the same expression replaces the previous task
238
+ * instead of stacking a new one.
239
+ * @param {string} expression — cron expression, e.g. "0 9 * * 1"
240
+ * @param {Function} fn — async function to run on schedule
241
+ * @returns {{ stop(): void }}
242
+ */
243
+ schedule(expression, fn) {
244
+ return schedule(expression, fn, pluginName);
245
+ },
246
+ };
247
+ }
248
+ // ── Plugin registry API ───────────────────────────────────────────────────────
249
+ function buildPluginsApi(pluginRegistry) {
250
+ return {
251
+ /**
252
+ * Return public API of another plugin, or null if not active.
253
+ * @param {string} name
254
+ */
255
+ get(name) {
256
+ return pluginRegistry.get(name)?.exports ?? null;
257
+ },
258
+ /**
259
+ * Return public API of another plugin, or throw if not active.
260
+ * @param {string} name
261
+ */
262
+ require(name) {
263
+ const plugin = pluginRegistry.get(name);
264
+ if (!plugin || plugin.status !== "active")
265
+ throw new Error(`[plugins] dependency "${name}" does not exist or is not active`);
266
+ return plugin.exports;
267
+ },
268
+ /**
269
+ * Check if a plugin is active.
270
+ * @param {string} name
271
+ */
272
+ exists(name) {
273
+ return pluginRegistry.get(name)?.status === "active";
274
+ },
275
+ };
276
+ }
277
+ // ── Log API ───────────────────────────────────────────────────────────────────
278
+ const log = {
279
+ info: (...a) => logger.info(...a),
280
+ warn: (...a) => logger.warn(...a),
281
+ error: (...a) => logger.error(...a),
282
+ success: (...a) => logger.success(...a),
283
+ };
284
+ // ── Contact normalization ─────────────────────────────────────────────────────
285
+ /**
286
+ * Build a normalized contact object from a JID and optional store metadata.
287
+ * isBusiness is resolved via sock.getBusinessProfile(jid) — it resolves to
288
+ * a profile only for WhatsApp Business accounts, undefined otherwise.
289
+ * @param {string} jid
290
+ * @param {WAStoreContact} [info]
291
+ * @param {string|null} [botJid]
292
+ * @param {WASocket} [sock]
293
+ */
294
+ async function normalizeContact(jid, info, botJid, sock) {
295
+ const number = jid.split("@")[0];
296
+ let isBusiness = false;
297
+ // We already have a contact record for this jid (learned from a real
298
+ // contacts.upsert or an actual message from them) — that alone proves
299
+ // it's a real WhatsApp account. Only fall back to the onWhatsApp() query
300
+ // when we don't, since that query is PN-oriented and unreliable for a
301
+ // raw @lid we've never resolved to a phone number (returns a false
302
+ // "doesn't exist" instead of throwing).
303
+ let isWAAccount = Boolean(info);
304
+ if (!isWAAccount && sock && !jid.endsWith("@g.us")) {
305
+ try {
306
+ const results = await sock.onWhatsApp(jid);
307
+ isWAAccount = Boolean(results?.[0]?.exists);
308
+ }
309
+ catch {
310
+ // Couldn't verify — leave as false rather than claiming certainty.
311
+ isWAAccount = false;
312
+ }
313
+ }
314
+ if (sock && !jid.endsWith("@g.us")) {
315
+ try {
316
+ isBusiness = Boolean(await sock.getBusinessProfile(jid));
317
+ }
318
+ catch {
319
+ isBusiness = false;
320
+ }
321
+ }
322
+ return {
323
+ id: jid,
324
+ number,
325
+ pushname: info?.notify ?? null,
326
+ name: info?.name ?? info?.verifiedName ?? null,
327
+ // Baileys' Contact type has no "short name" equivalent (that's a
328
+ // whatsapp-web.js/vCard concept) — always null here, not a bug.
329
+ shortName: null,
330
+ isBusiness,
331
+ isEnterprise: false,
332
+ isBlocked: false,
333
+ isMe: botJid ? jid === normalizeJid(botJid) : false,
334
+ isWAAccount,
335
+ isUser: !jid.endsWith("@g.us"),
336
+ isGroup: jid.endsWith("@g.us"),
337
+ mention: { text: `@${number}`, mentions: [jid] },
338
+ };
339
+ }
340
+ // ── Contact API ───────────────────────────────────────────────────────────────
341
+ function buildContactsApi(sock, store, botJid) {
342
+ return {
343
+ /**
344
+ * Get a normalized contact object by JID.
345
+ * @param {string} contactId
346
+ * @returns {Promise<object|null>}
347
+ */
348
+ async get(contactId) {
349
+ const resolved = normalizeJid(store.resolveJid(normalizeJid(contactId)));
350
+ // The same person's data can be split across the raw (e.g. @lid) and
351
+ // store-resolved (PN) keys — one may have `notify` and the other
352
+ // `verifiedName`/`name`. Merge instead of taking whichever key
353
+ // happens to exist first, or we silently drop real fields.
354
+ const raw = store.contacts[contactId];
355
+ const resolvedInfo = store.contacts[resolved];
356
+ const info = (raw || resolvedInfo) ? { ...raw, ...resolvedInfo } : undefined;
357
+ return normalizeContact(resolved, info, botJid, sock);
358
+ },
359
+ /**
360
+ * Get the profile picture URL of a contact.
361
+ * @param {string} contactId
362
+ * @returns {Promise<string|null>}
363
+ */
364
+ async getPfpUrl(contactId) {
365
+ const resolved = normalizeJid(store.resolveJid(normalizeJid(contactId)));
366
+ try {
367
+ const url = await sock.profilePictureUrl(resolved, "image");
368
+ return url ?? null;
369
+ }
370
+ catch {
371
+ return null;
372
+ }
373
+ },
374
+ /**
375
+ * Download a contact's profile picture to a local path.
376
+ * @param {string} contactId
377
+ * @param {string} destPath — absolute path (e.g. via ctx.storage.resolve)
378
+ * @returns {Promise<string|null>}
379
+ */
380
+ async getPfpPath(contactId, destPath) {
381
+ const url = await this.getPfpUrl(contactId);
382
+ if (!url)
383
+ return null;
384
+ try {
385
+ const res = await fetch(url);
386
+ if (!res.ok)
387
+ return null;
388
+ await writeFile(destPath, Buffer.from(await res.arrayBuffer()));
389
+ return destPath;
390
+ }
391
+ catch {
392
+ return null;
393
+ }
394
+ },
395
+ /**
396
+ * Get the "about" / status text of a contact.
397
+ * @param {string} contactId
398
+ * @returns {Promise<string|null>}
399
+ */
400
+ async getAbout(contactId) {
401
+ // fetchStatus is a legacy/USync query — pass the store-resolved JID
402
+ // (PN when known) rather than a raw @lid, which it may not accept.
403
+ const resolved = normalizeJid(store.resolveJid(normalizeJid(contactId)));
404
+ try {
405
+ const res = await sock.fetchStatus(resolved);
406
+ // Current Baileys versions return a USync result array
407
+ // (`[{ id, status: { status, setAt } }]`) instead of the legacy
408
+ // single `{ status }` object — handle both shapes.
409
+ if (Array.isArray(res)) {
410
+ const entry = res.find(r => normalizeJid(r.id) === resolved) ?? res[0];
411
+ return entry?.status?.status ?? null;
412
+ }
413
+ return res?.status ?? null;
414
+ }
415
+ catch (err) {
416
+ // Previously swallowed silently, which made "always null" look
417
+ // identical to "no status set" — log so real failures are visible.
418
+ logger.warn(`[contacts] getAbout(${contactId}) failed: ${err}`);
419
+ return null;
420
+ }
421
+ },
422
+ /**
423
+ * Block a contact.
424
+ * @param {string} contactId
425
+ */
426
+ async block(contactId) {
427
+ return sock.updateBlockStatus(contactId, "block");
428
+ },
429
+ /**
430
+ * Unblock a contact.
431
+ * @param {string} contactId
432
+ */
433
+ async unblock(contactId) {
434
+ return sock.updateBlockStatus(contactId, "unblock");
435
+ },
436
+ };
437
+ }
438
+ export function buildMessageContext(msg, sock, store, guardOptions = {}) {
439
+ const body = getMsgBody(msg);
440
+ const prefix = CONFIG.CMD_PREFIX;
441
+ const rawArgs = body.trim().split(/\s+/);
442
+ const first = rawArgs[0]?.toLowerCase() ?? "";
443
+ const hasPrefix = first.startsWith(prefix);
444
+ const command = hasPrefix ? first.slice(prefix.length) : "";
445
+ const rawJid = msg.key.remoteJid ?? "";
446
+ const sender = getMsgSender(msg, store);
447
+ const cooldown = guardOptions.cooldown ?? true;
448
+ const jitter = guardOptions.jitter ?? true;
449
+ const contextInfo = getContextInfo(msg);
450
+ const quotedRaw = contextInfo?.quotedMessage
451
+ ? {
452
+ key: {
453
+ remoteJid: rawJid,
454
+ fromMe: false,
455
+ id: contextInfo.stanzaId ?? undefined,
456
+ participant: contextInfo.participant ?? undefined,
457
+ },
458
+ message: contextInfo.quotedMessage,
459
+ pushName: null,
460
+ }
461
+ : null;
462
+ return {
463
+ body,
464
+ type: getMsgType(msg),
465
+ fromMe: !!(msg.key.fromMe),
466
+ sender,
467
+ senderName: msg.pushName ?? sender.replace(/(:\d+)?@.*$/, ""),
468
+ command,
469
+ args: rawArgs.slice(1),
470
+ is(cmd) {
471
+ return hasPrefix && command === cmd.toLowerCase();
472
+ },
473
+ hasMedia: msgHasMedia(msg),
474
+ isGif: msgIsGif(msg),
475
+ async downloadMedia() {
476
+ try {
477
+ const buffer = await downloadMediaMessage(msg, "buffer", {});
478
+ if (!buffer || !Buffer.isBuffer(buffer))
479
+ return null;
480
+ return { mimetype: getMsgMimetype(msg), data: buffer.toString("base64") };
481
+ }
482
+ catch {
483
+ return null;
484
+ }
485
+ },
486
+ hasReply: !!(contextInfo?.quotedMessage),
487
+ async getReply() {
488
+ return quotedRaw;
489
+ },
490
+ reply: makeSender(sock, store, rawJid, msg, { cooldown, jitter }),
491
+ async react(emoji) {
492
+ return sock.sendMessage(rawJid, { react: { text: emoji, key: msg.key } });
493
+ },
494
+ async delete(forEveryone = true) {
495
+ if (forEveryone) {
496
+ return sock.sendMessage(rawJid, { delete: msg.key });
497
+ }
498
+ },
499
+ async pin(_duration) {
500
+ logger.warn("[pluginApi] pin() is not supported with Baileys");
501
+ },
502
+ hasPrefix,
503
+ async getContact() {
504
+ const info = store.contacts[sender]
505
+ ?? store.contacts[store.resolveJid(msg.key.participant ?? "")];
506
+ const botJid = sock.user?.id ? normalizeJid(sock.user.id) : null;
507
+ return normalizeContact(sender, info, botJid, sock);
508
+ },
509
+ };
510
+ }
511
+ // ── MessageHandle ─────────────────────────────────────────────────────────────
512
+ /**
513
+ * Wraps a pending send and exposes chainable post-send actions.
514
+ * Thenable: `await ctx.send.text("hi")` resolves to the message context.
515
+ */
516
+ class MessageHandle {
517
+ _p;
518
+ _sock;
519
+ _store;
520
+ _jid = null;
521
+ _guardOptions;
522
+ rawPromise;
523
+ constructor(promise, sock, store, guardOptions) {
524
+ this.rawPromise = promise;
525
+ this._sock = sock;
526
+ this._store = store;
527
+ this._guardOptions = guardOptions ?? {};
528
+ this._p = promise.then(msg => {
529
+ if (!msg)
530
+ return undefined;
531
+ if (!this._jid)
532
+ this._jid = msg.key.remoteJid || null;
533
+ return buildMessageContext(msg, sock, store, guardOptions);
534
+ });
535
+ }
536
+ then(onfulfilled, onrejected) {
537
+ return this._p.then(onfulfilled, onrejected);
538
+ }
539
+ catch(onrejected) {
540
+ return this._p.catch(onrejected);
541
+ }
542
+ finally(onfinally) {
543
+ return this._p.finally(onfinally);
544
+ }
545
+ /**
546
+ * Reply to the sent message.
547
+ * Returns a sender that will quote this message when methods are called.
548
+ *
549
+ * Usage:
550
+ * const audio = await ctx.send.audio("file.mp3");
551
+ * const reply = await audio.reply.text("here it is!");
552
+ */
553
+ get reply() {
554
+ return makeSender(this._sock, this._store, this._jid || "", this.rawPromise, this._guardOptions);
555
+ }
556
+ /** Pin the sent message. */
557
+ async pin(_duration) {
558
+ logger.warn("[pluginApi] pin() is not supported yet");
559
+ }
560
+ /** Delete the sent message. */
561
+ async delete(forEveryone = true) {
562
+ const msg = await this.rawPromise;
563
+ if (!msg?.key)
564
+ return;
565
+ const jid = msg.key.remoteJid;
566
+ if (forEveryone) {
567
+ return this._sock.sendMessage(jid, { delete: msg.key });
568
+ }
569
+ }
570
+ /** React to the sent message. */
571
+ async react(emoji) {
572
+ const msg = await this.rawPromise;
573
+ if (!msg?.key)
574
+ return;
575
+ return this._sock.sendMessage(msg.key.remoteJid, {
576
+ react: { text: emoji, key: msg.key },
577
+ });
578
+ }
579
+ }
580
+ // ── Sender factory ────────────────────────────────────────────────────────────
581
+ /**
582
+ * Returns send methods bound to a specific JID.
583
+ *
584
+ * @param {WASocket} sock
585
+ * @param {WAStore} store
586
+ * @param {string} jid — destination JID (raw, not normalized)
587
+ * @param {WAProtoMsg | Promise<WAProtoMsg | null>} [quoted] — message to quote (can be Promise)
588
+ * @param {object} [guard]
589
+ */
590
+ function makeSender(sock, store, jid, quoted = null, { cooldown = true, jitter = true } = {}) {
591
+ const normJid = normalizeJid(jid);
592
+ // Helper: resolve quoted message if it's a Promise
593
+ const resolveQuoted = async () => {
594
+ if (!quoted)
595
+ return undefined;
596
+ if (quoted instanceof Promise) {
597
+ const result = await quoted;
598
+ return result || undefined;
599
+ }
600
+ return quoted;
601
+ };
602
+ return {
603
+ text(content, opts = {}) {
604
+ return new MessageHandle((async () => {
605
+ const quotedMsg = await resolveQuoted();
606
+ const sendOpts = quotedMsg ? { quoted: quotedMsg } : undefined;
607
+ if (opts.linkPreview === false) {
608
+ sendOpts.linkPreview = false;
609
+ }
610
+ if (opts.mentions?.length) {
611
+ sendOpts.mentions = opts.mentions;
612
+ }
613
+ await waitForSendSlot(normJid, { cooldown, jitter });
614
+ await simulateState(toPresenceCapable(sock), jid, typingDuration(content), "typing");
615
+ return sock.sendMessage(jid, { text: content }, sendOpts);
616
+ })(), sock, store, { cooldown, jitter });
617
+ },
618
+ image(filePath, caption = "", opts = {}) {
619
+ return new MessageHandle((async () => {
620
+ const quotedMsg = await resolveQuoted();
621
+ const sendOpts = quotedMsg ? { quoted: quotedMsg } : undefined;
622
+ if (opts.viewOnce) {
623
+ sendOpts.viewOnce = true;
624
+ }
625
+ await waitForSendSlot(normJid, { cooldown, jitter });
626
+ await simulateState(toPresenceCapable(sock), jid, mediaDuration(), "typing");
627
+ const buffer = await readFile(filePath);
628
+ return sock.sendMessage(jid, { image: buffer, caption }, sendOpts);
629
+ })(), sock, store, { cooldown, jitter });
630
+ },
631
+ video(filePath, caption = "", opts = {}) {
632
+ return new MessageHandle((async () => {
633
+ const quotedMsg = await resolveQuoted();
634
+ const sendOpts = quotedMsg ? { quoted: quotedMsg } : undefined;
635
+ if (opts.viewOnce) {
636
+ sendOpts.viewOnce = true;
637
+ }
638
+ await waitForSendSlot(normJid, { cooldown, jitter });
639
+ await simulateState(toPresenceCapable(sock), jid, mediaDuration(), "typing");
640
+ const buffer = await readFile(filePath);
641
+ return sock.sendMessage(jid, { video: buffer, caption }, sendOpts);
642
+ })(), sock, store, { cooldown, jitter });
643
+ },
644
+ audio(filePath, { asVoice = true, viewOnce = false } = {}) {
645
+ return new MessageHandle((async () => {
646
+ const quotedMsg = await resolveQuoted();
647
+ const sendOpts = quotedMsg ? { quoted: quotedMsg } : undefined;
648
+ if (viewOnce) {
649
+ sendOpts.viewOnce = true;
650
+ }
651
+ await waitForSendSlot(normJid, { cooldown, jitter });
652
+ await simulateState(toPresenceCapable(sock), jid, mediaDuration(), "recording");
653
+ const buffer = await readFile(filePath);
654
+ return sock.sendMessage(jid, { audio: buffer, mimetype: "audio/mp4", ptt: asVoice }, sendOpts);
655
+ })(), sock, store, { cooldown, jitter });
656
+ },
657
+ sticker(source) {
658
+ return new MessageHandle((async () => {
659
+ const quotedMsg = await resolveQuoted();
660
+ const qOpts = quotedMsg ? { quoted: quotedMsg } : undefined;
661
+ await waitForSendSlot(normJid, { cooldown, jitter });
662
+ await simulateState(toPresenceCapable(sock), jid, mediaDuration(), "typing");
663
+ const buffer = Buffer.isBuffer(source) ? source : await readFile(source);
664
+ return sock.sendMessage(jid, { sticker: buffer }, qOpts);
665
+ })(), sock, store, { cooldown, jitter });
666
+ },
667
+ file(filePath, filename) {
668
+ return new MessageHandle((async () => {
669
+ const quotedMsg = await resolveQuoted();
670
+ const qOpts = quotedMsg ? { quoted: quotedMsg } : undefined;
671
+ await waitForSendSlot(normJid, { cooldown, jitter });
672
+ await simulateState(toPresenceCapable(sock), jid, mediaDuration(), "typing");
673
+ const buffer = await readFile(filePath);
674
+ const mimetype = mimeFromPath(filePath);
675
+ return sock.sendMessage(jid, {
676
+ document: buffer,
677
+ mimetype,
678
+ fileName: filename ?? path.basename(filePath),
679
+ }, qOpts);
680
+ })(), sock, store, { cooldown, jitter });
681
+ },
682
+ /**
683
+ * Send a poll.
684
+ * @param {string} question
685
+ * @param {string[]} options
686
+ * @param {object} [opts]
687
+ * @param {boolean} [opts.allowMultipleAnswers=false]
688
+ */
689
+ poll(question, options, { allowMultipleAnswers = false } = {}) {
690
+ return new MessageHandle((async () => {
691
+ const quotedMsg = await resolveQuoted();
692
+ const qOpts = quotedMsg ? { quoted: quotedMsg } : undefined;
693
+ await waitForSendSlot(normJid, { cooldown, jitter });
694
+ return sock.sendMessage(jid, {
695
+ poll: {
696
+ name: question,
697
+ values: options,
698
+ selectableCount: allowMultipleAnswers ? 0 : 1,
699
+ },
700
+ }, qOpts);
701
+ })(), sock, store, { cooldown, jitter });
702
+ },
703
+ };
704
+ }
705
+ // ── Send API ──────────────────────────────────────────────────────────────────
706
+ function buildSendApi(sock, store, rawJid, guardOptions = {}) {
707
+ const cooldown = (guardOptions.cooldown ?? true);
708
+ const jitter = (guardOptions.jitter ?? true);
709
+ const current = makeSender(sock, store, rawJid, null, { cooldown, jitter });
710
+ return {
711
+ send: {
712
+ text: (text, opts) => current.text(text, opts),
713
+ image: (filePath, caption) => current.image(filePath, caption),
714
+ video: (filePath, caption) => current.video(filePath, caption),
715
+ audio: (filePath, opts) => current.audio(filePath, opts),
716
+ sticker: (source) => current.sticker(source),
717
+ file: (filePath, filename) => current.file(filePath, filename),
718
+ poll: (q, opts, cfg) => current.poll(q, opts, cfg),
719
+ /**
720
+ * Returns a sender bound to another chat.
721
+ * @param {string} targetJid
722
+ */
723
+ to: (targetJid) => makeSender(sock, store, targetJid, null, { cooldown: false, jitter: false }),
724
+ },
725
+ };
726
+ }
727
+ /** Setup send API — no current chat, only .to(). */
728
+ function buildSetupSendApi(sock, store) {
729
+ return {
730
+ send: {
731
+ to: (targetJid) => makeSender(sock, store, targetJid),
732
+ },
733
+ };
734
+ }
735
+ // ── Events API ────────────────────────────────────────────────────────────────
736
+ const listenerRegistry = new Map();
737
+ export function cleanupPluginEvents(pluginName, sock) {
738
+ const list = listenerRegistry.get(pluginName);
739
+ if (list) {
740
+ for (const { event, handler } of list) {
741
+ try {
742
+ sock.ev.off(event, handler);
743
+ }
744
+ catch { }
745
+ }
746
+ listenerRegistry.delete(pluginName);
747
+ }
748
+ cancelPlugin(pluginName);
749
+ }
750
+ /**
751
+ * @param {WASocket} sock
752
+ * @param {string} pluginName
753
+ */
754
+ function buildEventsApi(sock, pluginName) {
755
+ return {
756
+ on(event, handler) {
757
+ sock.ev.on(event, handler);
758
+ if (!listenerRegistry.has(pluginName))
759
+ listenerRegistry.set(pluginName, new Set());
760
+ const ref = { event, handler };
761
+ listenerRegistry.get(pluginName).add(ref);
762
+ return () => {
763
+ sock.ev.off(event, handler);
764
+ listenerRegistry.get(pluginName)?.delete(ref);
765
+ };
766
+ },
767
+ once(event) {
768
+ return new Promise(resolve => {
769
+ const off = this.on(event, (data) => { off(); resolve(data); });
770
+ });
771
+ },
772
+ cleanup() {
773
+ const list = listenerRegistry.get(pluginName);
774
+ if (!list)
775
+ return;
776
+ for (const { event, handler } of list)
777
+ sock.ev.off(event, handler);
778
+ listenerRegistry.delete(pluginName);
779
+ },
780
+ };
781
+ }
782
+ // ── Admin API ─────────────────────────────────────────────────────────────────
783
+ /**
784
+ * Group administration actions.
785
+ *
786
+ * @param {WASocket} sock
787
+ * @param {string|null} chatJid — raw JID of the current group (null in setup context)
788
+ */
789
+ function buildAdminApi(sock, chatJid) {
790
+ const norm = (v) => Array.isArray(v) ? v : [v];
791
+ function requireChat() {
792
+ if (!chatJid)
793
+ throw new Error("This admin operation requires a runtime group context.");
794
+ }
795
+ async function getGroup(jid) {
796
+ const meta = await sock.groupMetadata(jid);
797
+ if (!meta)
798
+ throw new Error(`Group not found: ${jid}`);
799
+ return meta;
800
+ }
801
+ function createTargetableAction(action, memberIds) {
802
+ const users = norm(memberIds);
803
+ const executeCurrent = async () => { requireChat(); return action(chatJid, users); };
804
+ return {
805
+ async to(targetJid) { await getGroup(targetJid); return action(targetJid, users); },
806
+ then(onfulfilled, onrejected) {
807
+ return executeCurrent().then(onfulfilled, onrejected);
808
+ },
809
+ catch(onrejected) {
810
+ return executeCurrent().catch(onrejected);
811
+ },
812
+ finally(onfinally) {
813
+ return executeCurrent().finally(onfinally);
814
+ },
815
+ };
816
+ }
817
+ return {
818
+ /** @param {string|string[]} memberIds */
819
+ add(memberIds) {
820
+ return createTargetableAction((jid, users) => sock.groupParticipantsUpdate(jid, users, "add"), memberIds);
821
+ },
822
+ /** @param {string|string[]} memberIds */
823
+ async kick(memberIds) {
824
+ requireChat();
825
+ return sock.groupParticipantsUpdate(chatJid, norm(memberIds), "remove");
826
+ },
827
+ /** @param {string|string[]} memberIds */
828
+ async promote(memberIds) {
829
+ requireChat();
830
+ return sock.groupParticipantsUpdate(chatJid, norm(memberIds), "promote");
831
+ },
832
+ /** @param {string|string[]} memberIds */
833
+ async demote(memberIds) {
834
+ requireChat();
835
+ return sock.groupParticipantsUpdate(chatJid, norm(memberIds), "demote");
836
+ },
837
+ /** @param {string} name */
838
+ async setSubject(name) {
839
+ requireChat();
840
+ return sock.groupUpdateSubject(chatJid, name);
841
+ },
842
+ /** @param {string} text */
843
+ async setDescription(text) {
844
+ requireChat();
845
+ return sock.groupUpdateDescription(chatJid, text);
846
+ },
847
+ /** @param {string|Buffer} source */
848
+ async setProfilePic(source) {
849
+ requireChat();
850
+ const buffer = Buffer.isBuffer(source) ? source : readFileSync(source);
851
+ return sock.updateProfilePicture(chatJid, buffer);
852
+ },
853
+ async getInviteLink() {
854
+ requireChat();
855
+ const code = await sock.groupInviteCode(chatJid);
856
+ return `https://chat.whatsapp.com/${code}`;
857
+ },
858
+ async revokeInvite() {
859
+ requireChat();
860
+ return sock.groupRevokeInvite(chatJid);
861
+ },
862
+ };
863
+ }
864
+ // ── Me API ────────────────────────────────────────────────────────────────────
865
+ /** @param {WASocket} sock */
866
+ function buildMeApi(sock) {
867
+ return {
868
+ /** @param {string} name */
869
+ async setName(name) {
870
+ return sock.updateProfileName(name);
871
+ },
872
+ /** @param {string} text */
873
+ async setAbout(text) {
874
+ return sock.updateProfileStatus(text);
875
+ },
876
+ /** @param {string|Buffer} source */
877
+ async setProfilePic(source) {
878
+ const buffer = Buffer.isBuffer(source) ? source : readFileSync(source);
879
+ const jid = sock.user?.id ?? "";
880
+ return sock.updateProfilePicture(jid, buffer);
881
+ },
882
+ };
883
+ }
884
+ // ── Poll API ──────────────────────────────────────────────────────────────────
885
+ const pollRegistry = new Map();
886
+ const pollListeners = new Set();
887
+ /**
888
+ * Tracks votes for an active poll.
889
+ * Obtained via ctx.poll.create().
890
+ */
891
+ class PollHandle {
892
+ msgId;
893
+ _options;
894
+ _callbacks;
895
+ _registry;
896
+ constructor(msg, options, registry) {
897
+ this.msgId = msg.key.id ?? "";
898
+ this._options = new Map(options.map(o => [o, new Set()]));
899
+ this._callbacks = [];
900
+ this._registry = registry;
901
+ }
902
+ /** Update from Baileys aggregated vote result. */
903
+ _updateFromAggregated(aggregated) {
904
+ // Reset all counts then rebuild from aggregate
905
+ for (const voters of this._options.values())
906
+ voters.clear();
907
+ for (const { name, voters } of aggregated) {
908
+ if (this._options.has(name))
909
+ this._options.set(name, new Set(voters));
910
+ }
911
+ for (const cb of this._callbacks)
912
+ cb(this.results(), aggregated);
913
+ }
914
+ /**
915
+ * Register a callback invoked on every vote change.
916
+ * @param cb Receives (results, raw)
917
+ */
918
+ onVote(cb) {
919
+ this._callbacks.push(cb);
920
+ return this;
921
+ }
922
+ /** Current tally as a plain object. */
923
+ results() {
924
+ const out = {};
925
+ for (const [name, voters] of this._options)
926
+ out[name] = voters.size;
927
+ return out;
928
+ }
929
+ /** Name(s) of the leading option(s). Returns [] if no votes yet. */
930
+ winner() {
931
+ const res = this.results();
932
+ const counts = Object.values(res);
933
+ if (!counts.length)
934
+ return [];
935
+ const max = Math.max(...counts);
936
+ if (max === 0)
937
+ return [];
938
+ return Object.entries(res).filter(([, v]) => v === max).map(([k]) => k);
939
+ }
940
+ /** Remove this poll from tracking. */
941
+ close() {
942
+ this._registry.delete(this.msgId);
943
+ }
944
+ }
945
+ /**
946
+ * @param {WASocket} sock
947
+ * @param {WAStore} store
948
+ * @param {string} rawJid — destination JID (not normalized)
949
+ * @param {object} guardOptions
950
+ * @param {string} pluginName
951
+ */
952
+ function buildPollApi(sock, store, rawJid, guardOptions, pluginName) {
953
+ if (!pollRegistry.has(pluginName))
954
+ pollRegistry.set(pluginName, new Map());
955
+ const registry = pollRegistry.get(pluginName);
956
+ // Keyed by creationId -> (voterKey -> latest vote entry). WhatsApp resends
957
+ // the *entire current selection* on every tap (not a diff), and Baileys'
958
+ // getAggregateVotesInPollMessage() replays whatever pollUpdates you give it
959
+ // with no dedup — so we must keep only the latest entry per voter ourselves,
960
+ // or retracted/changed votes keep counting alongside the new one.
961
+ const pollVotesByCreationId = new Map();
962
+ if (!pollListeners.has(pluginName)) {
963
+ pollListeners.add(pluginName);
964
+ const meId = sock.user?.id ? jidNormalizedUser(sock.user.id) : "me";
965
+ // WhatsApp doesn't consistently use the same JID shape (LID vs PN) for
966
+ // pollCreatorJid/voterJid when deriving the poll-vote decryption key —
967
+ // it depends on addressingMode, 1:1 vs group, and which side sent last.
968
+ // Trying to compute "the" correct JID up front (as the old resolveAuthor
969
+ // did, always preferring participantPn) causes AES-GCM auth failures
970
+ // whenever WhatsApp actually used the LID for that message. Instead,
971
+ // gather every plausible JID for each side and brute-force combinations
972
+ // until one decrypts successfully — see
973
+ // https://github.com/WhiskeySockets/Baileys/issues/2342 and #1678.
974
+ function jidCandidates(key) {
975
+ const cands = [];
976
+ if (key.fromMe) {
977
+ const selfLid = sock.user?.lid;
978
+ if (selfLid)
979
+ cands.push(jidNormalizedUser(selfLid));
980
+ if (sock.user?.id)
981
+ cands.push(jidNormalizedUser(sock.user.id));
982
+ }
983
+ else {
984
+ const rawParticipant = key.participant ?? key.remoteJid;
985
+ if (rawParticipant)
986
+ cands.push(jidNormalizedUser(rawParticipant));
987
+ if (key.participantPn)
988
+ cands.push(jidNormalizedUser(key.participantPn));
989
+ if (rawParticipant?.endsWith("@lid")) {
990
+ const resolved = store.resolveJid(rawParticipant);
991
+ if (resolved && resolved !== rawParticipant)
992
+ cands.push(jidNormalizedUser(resolved));
993
+ }
994
+ }
995
+ return Array.from(new Set(cands.filter(Boolean)));
996
+ }
997
+ sock.ev.on("messages.upsert", async ({ messages: msgs }) => {
998
+ for (const msg of msgs) {
999
+ const pum = msg.message?.pollUpdateMessage;
1000
+ if (!pum)
1001
+ continue;
1002
+ const creationKey = pum.pollCreationMessageKey;
1003
+ const creationId = creationKey?.id ?? "";
1004
+ const handle = registry.get(creationId);
1005
+ if (!handle)
1006
+ continue;
1007
+ const storeMsg = store.messages.get(creationKey?.remoteJid ?? "")?.get(creationId);
1008
+ const pollEncKeyRaw = storeMsg?.message?.messageContextInfo?.messageSecret;
1009
+ if (!storeMsg || !pollEncKeyRaw || !pum.vote)
1010
+ continue;
1011
+ try {
1012
+ const pollEncKey = Buffer.isBuffer(pollEncKeyRaw)
1013
+ ? pollEncKeyRaw
1014
+ : Buffer.from(pollEncKeyRaw, "base64");
1015
+ const creatorCandidates = jidCandidates((creationKey ?? {}));
1016
+ const voterCandidates = jidCandidates(msg.key);
1017
+ let decryptedVote;
1018
+ for (const pollCreatorJid of creatorCandidates) {
1019
+ for (const voterJid of voterCandidates) {
1020
+ try {
1021
+ decryptedVote = decryptPollVote(pum.vote, {
1022
+ pollEncKey,
1023
+ pollCreatorJid,
1024
+ pollMsgId: creationId,
1025
+ voterJid,
1026
+ });
1027
+ break;
1028
+ }
1029
+ catch {
1030
+ // try next JID combination
1031
+ }
1032
+ }
1033
+ if (decryptedVote)
1034
+ break;
1035
+ }
1036
+ if (!decryptedVote) {
1037
+ throw new Error(`all JID combinations failed (creator=${JSON.stringify(creatorCandidates)}, voter=${JSON.stringify(voterCandidates)})`);
1038
+ }
1039
+ const voterKey = msg.key.fromMe
1040
+ ? meId
1041
+ : jidNormalizedUser(msg.key.participant ?? msg.key.remoteJid ?? "");
1042
+ const votesByVoter = pollVotesByCreationId.get(creationId) ?? new Map();
1043
+ votesByVoter.set(voterKey, {
1044
+ pollUpdateMessageKey: msg.key,
1045
+ vote: decryptedVote,
1046
+ senderTimestampMs: pum.senderTimestampMs,
1047
+ });
1048
+ pollVotesByCreationId.set(creationId, votesByVoter);
1049
+ const aggregated = getAggregateVotesInPollMessage({ message: storeMsg.message, pollUpdates: Array.from(votesByVoter.values()) }, meId);
1050
+ handle._updateFromAggregated(aggregated);
1051
+ }
1052
+ catch (err) {
1053
+ logger.error(`[poll] erro ao decriptar voto: ${err}`);
1054
+ }
1055
+ }
1056
+ });
1057
+ }
1058
+ const cooldown = (guardOptions.cooldown ?? true);
1059
+ const jitter = (guardOptions.jitter ?? true);
1060
+ return {
1061
+ /**
1062
+ * Send a poll and start tracking votes.
1063
+ * @param {string} question
1064
+ * @param {string[]} options
1065
+ * @param {object} [opts]
1066
+ * @param {boolean} [opts.allowMultipleAnswers=false]
1067
+ * @returns {Promise<PollHandle>}
1068
+ */
1069
+ async create(question, options, opts = {}) {
1070
+ const sender = makeSender(sock, store, rawJid, null, { cooldown, jitter });
1071
+ const handlePromise = sender.poll(question, options, opts);
1072
+ const rawMsg = await handlePromise.rawPromise;
1073
+ if (!rawMsg)
1074
+ throw new Error("[poll] failed to send poll message");
1075
+ const handle = new PollHandle(rawMsg, options, registry);
1076
+ registry.set(handle.msgId, handle);
1077
+ // Ensure the poll message is in the store before any vote arrives —
1078
+ // messages.upsert isn't guaranteed to fire (or land in time) for the
1079
+ // bot's own sent messages, which previously dropped every vote.
1080
+ const remoteJid = rawMsg.key.remoteJid;
1081
+ if (remoteJid) {
1082
+ if (!store.messages.has(remoteJid))
1083
+ store.messages.set(remoteJid, new Map());
1084
+ if (!store.messages.get(remoteJid).has(handle.msgId)) {
1085
+ store.messages.get(remoteJid).set(handle.msgId, rawMsg);
1086
+ }
1087
+ }
1088
+ return handle;
1089
+ },
1090
+ /**
1091
+ * Retrieve an active PollHandle by its message ID.
1092
+ * @param {string} msgId
1093
+ */
1094
+ get(msgId) {
1095
+ return registry.get(msgId) ?? null;
1096
+ },
1097
+ };
1098
+ }
1099
+ // ── Base API (shared between setup and runtime) ───────────────────────────────
1100
+ function buildBaseApi(sock, store, pluginRegistry, pluginName) {
1101
+ const botJid = sock.user?.id ? normalizeJid(sock.user.id) : null;
1102
+ if (!botJid)
1103
+ logger.warn("[pluginApi] botId is null — socket may not be ready yet.");
1104
+ return {
1105
+ log,
1106
+ t,
1107
+ config: buildConfigApi(),
1108
+ i18n: buildI18nApi(),
1109
+ utils: buildUtilsApi(),
1110
+ download: buildDownloadApi(),
1111
+ scheduler: buildSchedulerApi(pluginName),
1112
+ plugins: buildPluginsApi(pluginRegistry),
1113
+ contacts: buildContactsApi(sock, store, botJid),
1114
+ storage: buildStorageApi(pluginName),
1115
+ botId: botJid,
1116
+ };
1117
+ }
1118
+ // ── Setup API ─────────────────────────────────────────────────────────────────
1119
+ /**
1120
+ * Setup API — without message context.
1121
+ * Passed to plugin.setup(ctx) during initialization.
1122
+ *
1123
+ * @param {WASocket} sock
1124
+ * @param {WAStore} store
1125
+ * @param {Map<string, any>} pluginRegistry
1126
+ * @param {string} pluginName
1127
+ */
1128
+ export function buildSetupApi(sock, store, pluginRegistry, pluginName) {
1129
+ return {
1130
+ ...buildBaseApi(sock, store, pluginRegistry, pluginName),
1131
+ ...buildSetupSendApi(sock, store),
1132
+ admin: buildAdminApi(sock, null),
1133
+ events: buildEventsApi(sock, pluginName),
1134
+ me: buildMeApi(sock),
1135
+ settings: { global: buildSettingsApi(pluginName, "_global").global },
1136
+ };
1137
+ }
1138
+ // ── Runtime API ───────────────────────────────────────────────────────────────
1139
+ /**
1140
+ * Runtime API — full context with message and chat.
1141
+ * Passed to plugin.default(ctx) on every message.
1142
+ *
1143
+ * @param {object} params
1144
+ * @param {WAProtoMsg} params.msg
1145
+ * @param {WAChat} params.chat
1146
+ * @param {WASocket} params.sock
1147
+ * @param {WAStore} params.store
1148
+ * @param {Map} params.pluginRegistry
1149
+ * @param {string} params.pluginName
1150
+ * @param {object} [params.guardOptions]
1151
+ */
1152
+ export function buildApi({ msg, chat, sock, store, pluginRegistry, pluginName, guardOptions = {}, }) {
1153
+ const prefix = CONFIG.CMD_PREFIX;
1154
+ const body = getMsgBody(msg);
1155
+ const rawArgs = body.trim().split(/\s+/);
1156
+ const first = rawArgs[0]?.toLowerCase() ?? "";
1157
+ const hasPrefix = first.startsWith(prefix);
1158
+ const command = hasPrefix ? first.slice(prefix.length) : "";
1159
+ const rawJid = msg.key.remoteJid ?? "";
1160
+ const normJid = normalizeJid(rawJid);
1161
+ const sender = getMsgSender(msg, store);
1162
+ const cooldown = (guardOptions.cooldown ?? true);
1163
+ const jitter = (guardOptions.jitter ?? true);
1164
+ // Sender for quoted messages
1165
+ const contextInfo = getContextInfo(msg);
1166
+ const quotedRaw = contextInfo?.quotedMessage
1167
+ ? {
1168
+ key: {
1169
+ remoteJid: rawJid,
1170
+ fromMe: false,
1171
+ id: contextInfo.stanzaId ?? undefined,
1172
+ participant: contextInfo.participant ?? undefined,
1173
+ },
1174
+ message: contextInfo.quotedMessage,
1175
+ pushName: null,
1176
+ }
1177
+ : null;
1178
+ // Group participant JIDs come back in whatever addressing mode the group
1179
+ // uses (@lid or @s.whatsapp.net/@c.us) — same issue as poll vote decryption.
1180
+ // "sender" and "sock.user.id" are usually PN-normalized, so a straight
1181
+ // `=== ` against an @lid participant list silently never matches, even
1182
+ // when the person genuinely is an admin. Compare every known form
1183
+ // (raw + store-resolved) on both sides instead of trusting a single shape.
1184
+ function matchesParticipant(candidates, participantId) {
1185
+ const pRaw = normalizeJid(participantId);
1186
+ const pResolved = normalizeJid(store.resolveJid(pRaw));
1187
+ for (const raw of candidates) {
1188
+ if (!raw)
1189
+ continue;
1190
+ const c = normalizeJid(raw);
1191
+ const cResolved = normalizeJid(store.resolveJid(c));
1192
+ if (c === pRaw || c === pResolved || cResolved === pRaw || cResolved === pResolved)
1193
+ return true;
1194
+ }
1195
+ return false;
1196
+ }
1197
+ return {
1198
+ ...buildBaseApi(sock, store, pluginRegistry, pluginName),
1199
+ ...buildSendApi(sock, store, rawJid, guardOptions),
1200
+ // ── msg ──────────────────────────────────────────────────────────────────
1201
+ msg: buildMessageContext(msg, sock, store, { cooldown, jitter }),
1202
+ // ── chat ─────────────────────────────────────────────────────────────────
1203
+ chat: {
1204
+ id: normJid,
1205
+ name: chat.name,
1206
+ isGroup: chat.isGroup,
1207
+ /**
1208
+ * List of group participants.
1209
+ * Returns [] for non-group chats.
1210
+ * @returns {Promise<Array<{ id: string, isAdmin: boolean, isSuperAdmin: boolean }>>}
1211
+ */
1212
+ async getParticipants() {
1213
+ if (!chat.isGroup)
1214
+ return [];
1215
+ try {
1216
+ const meta = await sock.groupMetadata(rawJid);
1217
+ return meta.participants.map(p => ({
1218
+ id: normalizeJid(p.id),
1219
+ isAdmin: p.admin === "admin" || p.admin === "superadmin",
1220
+ isSuperAdmin: p.admin === "superadmin",
1221
+ }));
1222
+ }
1223
+ catch {
1224
+ return [];
1225
+ }
1226
+ },
1227
+ /**
1228
+ * Check if a contact is an admin of this group.
1229
+ * @param {string} contactId
1230
+ * @returns {Promise<boolean>}
1231
+ */
1232
+ async isAdmin(contactId) {
1233
+ if (!chat.isGroup)
1234
+ return false;
1235
+ try {
1236
+ const meta = await sock.groupMetadata(rawJid);
1237
+ return meta.participants.some(p => matchesParticipant([contactId], p.id) && (p.admin === "admin" || p.admin === "superadmin"));
1238
+ }
1239
+ catch {
1240
+ return false;
1241
+ }
1242
+ },
1243
+ /**
1244
+ * Check if the message sender is an admin of this group.
1245
+ * @returns {Promise<boolean>}
1246
+ */
1247
+ async isSenderAdmin() {
1248
+ if (!chat.isGroup)
1249
+ return false;
1250
+ try {
1251
+ const meta = await sock.groupMetadata(rawJid);
1252
+ const rawSenderParticipant = msg.key.participant ?? msg.key.remoteJid ?? "";
1253
+ return meta.participants.some(p => matchesParticipant([sender, rawSenderParticipant], p.id) && (p.admin === "admin" || p.admin === "superadmin"));
1254
+ }
1255
+ catch {
1256
+ return false;
1257
+ }
1258
+ },
1259
+ /**
1260
+ * Check if the bot is an admin of this group.
1261
+ * @returns {Promise<boolean>}
1262
+ */
1263
+ async isBotAdmin() {
1264
+ if (!chat.isGroup)
1265
+ return false;
1266
+ const botLid = sock.user?.lid;
1267
+ const botCandidates = [sock.user?.id, botLid];
1268
+ if (!botCandidates.some(Boolean))
1269
+ return false;
1270
+ try {
1271
+ const meta = await sock.groupMetadata(rawJid);
1272
+ return meta.participants.some(p => matchesParticipant(botCandidates, p.id) && (p.admin === "admin" || p.admin === "superadmin"));
1273
+ }
1274
+ catch {
1275
+ return false;
1276
+ }
1277
+ },
1278
+ /** Clear all messages in this chat — not supported in Baileys. */
1279
+ async clearMessages() {
1280
+ logger.warn("[pluginApi] clearMessages() is not supported with Baileys");
1281
+ },
1282
+ },
1283
+ // ── admin ─────────────────────────────────────────────────────────────────
1284
+ admin: buildAdminApi(sock, rawJid),
1285
+ // ── me ────────────────────────────────────────────────────────────────────
1286
+ me: buildMeApi(sock),
1287
+ // ── poll ──────────────────────────────────────────────────────────────────
1288
+ poll: buildPollApi(sock, store, rawJid, guardOptions, pluginName),
1289
+ // ── settings ──────────────────────────────────────────────────────────────
1290
+ settings: buildSettingsApi(pluginName, normJid),
1291
+ // ── isolated platform contexts ────────────────────────────────────────────
1292
+ wa: {
1293
+ sock,
1294
+ store,
1295
+ msg,
1296
+ downloadMedia: async () => {
1297
+ try {
1298
+ const buffer = await downloadMediaMessage(msg, "buffer", {});
1299
+ if (!buffer || !Buffer.isBuffer(buffer))
1300
+ return null;
1301
+ return { mimetype: getMsgMimetype(msg), data: buffer.toString("base64") };
1302
+ }
1303
+ catch {
1304
+ return null;
1305
+ }
1306
+ }
1307
+ },
1308
+ tg: null,
1309
+ dc: null,
1310
+ };
1311
+ }