@manybot/manybot 5.9.1 → 5.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -178,6 +178,37 @@ function getMsgSenderPn(msg) {
178
178
  return normalizeJid(msg.chatId);
179
179
  return null;
180
180
  }
181
+ /**
182
+ * Split a quoted message's `contextInfo.participant` into `fromLid`/`fromPn`
183
+ * the same way the adapter's `toBotMessage()` splits `key.participant` /
184
+ * `key.participantAlt` via `splitLidPn()` — see the comment there. Unlike
185
+ * the key, `contextInfo` carries only a single `participant` field (no
186
+ * companion "Alt"), and it holds whichever form matches the chat's current
187
+ * addressing mode: under the modern default "lid" mode that's the @lid
188
+ * form, under legacy "pn" mode it's the phone-number form. Blindly
189
+ * assigning it to `fromPn` (as the quoted-message synthetic used to)
190
+ * leaves `fromLid` unset — so `getMsgSender()` (which only reads
191
+ * `participantAlt`/`fromLid`) falls through to `null` — while
192
+ * `getMsgSenderPn()` happily normalizes and returns the @lid value
193
+ * verbatim, since it never checks the suffix. Route by suffix here, and
194
+ * fill in the other side from the store's learned LID<->PN mapping when
195
+ * one is known.
196
+ */
197
+ function splitQuotedParticipant(store, participant) {
198
+ if (!participant)
199
+ return {};
200
+ if (participant.endsWith("@lid")) {
201
+ const resolved = store.resolveJid(participant);
202
+ return {
203
+ fromLid: participant,
204
+ fromPn: resolved !== participant ? resolved : undefined,
205
+ };
206
+ }
207
+ return {
208
+ fromLid: store.resolvePn(participant) ?? undefined,
209
+ fromPn: participant,
210
+ };
211
+ }
181
212
  /** Quoted-message metadata as the rest of the api uses it. */
182
213
  function getQuotedContext(msg) {
183
214
  if (!msg.quotedKey)
@@ -935,16 +966,33 @@ export function buildMessageContext(msg, contract, store, guardOptions = {}) {
935
966
  const quotedRaw = contextInfo?.quotedMessage
936
967
  ? (() => {
937
968
  const decoded = decodeContent(contextInfo.quotedMessage);
969
+ const { fromLid: quotedFromLid, fromPn: quotedFromPn } = splitQuotedParticipant(store, contextInfo.participant);
970
+ // contextInfo itself never carries the quoted message's original
971
+ // send time (WhatsApp's ContextInfo proto has no timestamp field —
972
+ // only stanzaId/participant/quotedMessage). Best-effort recover it
973
+ // from the message store, keyed by the same chatId+stanzaId, when
974
+ // the original is still cached (it usually is, since a reply
975
+ // almost always quotes a recent message in the same chat). Stays
976
+ // 0 — same as before — only when the original has aged out of the
977
+ // store's per-chat cap or the chat history hasn't been seen yet.
978
+ const quotedOriginal = contextInfo.stanzaId
979
+ ? store.messages.get(msg.chatId)?.get(contextInfo.stanzaId)
980
+ : undefined;
981
+ const quotedTimestamp = quotedOriginal
982
+ ? Number(quotedOriginal.messageTimestamp ?? 0) * 1000
983
+ : 0;
938
984
  return {
939
985
  id: contextInfo.stanzaId ?? "",
940
986
  chatId: msg.chatId,
941
987
  fromMe: false,
942
988
  type: decoded.type,
943
989
  contentHash: "",
944
- timestamp: 0,
990
+ timestamp: quotedTimestamp,
945
991
  body: decoded.body,
946
992
  mimetype: decoded.mimetype,
947
- fromPn: contextInfo.participant ?? undefined,
993
+ fromLid: quotedFromLid,
994
+ fromPn: quotedFromPn,
995
+ participantAlt: quotedFromLid,
948
996
  pushName: lookupPushName(contextInfo.participant),
949
997
  _raw: {
950
998
  contextInfo: {
@@ -962,16 +1010,27 @@ export function buildMessageContext(msg, contract, store, guardOptions = {}) {
962
1010
  // key-only synthetic so hasReply()/getReply() still work, but
963
1011
  // hasMedia/downloadMedia on the result will degrade gracefully
964
1012
  // (type=other, mimetype=undefined).
965
- ? {
966
- id: msg.quotedKey.id ?? "",
967
- chatId: msg.chatId,
968
- fromMe: false,
969
- type: "other",
970
- contentHash: "",
971
- timestamp: 0,
972
- fromPn: msg.quotedKey.participant ?? undefined,
973
- pushName: lookupPushName(msg.quotedKey.participant),
974
- }
1013
+ ? (() => {
1014
+ const { fromLid: quotedFromLid, fromPn: quotedFromPn } = splitQuotedParticipant(store, msg.quotedKey.participant);
1015
+ const quotedOriginal = msg.quotedKey.id
1016
+ ? store.messages.get(msg.chatId)?.get(msg.quotedKey.id)
1017
+ : undefined;
1018
+ const quotedTimestamp = quotedOriginal
1019
+ ? Number(quotedOriginal.messageTimestamp ?? 0) * 1000
1020
+ : 0;
1021
+ return {
1022
+ id: msg.quotedKey.id ?? "",
1023
+ chatId: msg.chatId,
1024
+ fromMe: false,
1025
+ type: "other",
1026
+ contentHash: "",
1027
+ timestamp: quotedTimestamp,
1028
+ fromLid: quotedFromLid,
1029
+ fromPn: quotedFromPn,
1030
+ participantAlt: quotedFromLid,
1031
+ pushName: lookupPushName(msg.quotedKey.participant),
1032
+ };
1033
+ })()
975
1034
  : null;
976
1035
  return {
977
1036
  id: msg.id,
@@ -1818,50 +1877,69 @@ function buildAdminApi(contract, store, chatJid) {
1818
1877
  },
1819
1878
  };
1820
1879
  }
1880
+ /**
1881
+ * Same `.to(target)` shape as `createTargetableAction`, for group-level
1882
+ * ops that take no `memberIds` (setSubject/setDescription/setProfilePic/
1883
+ * revokeInvite) — no participant resolution needed, just a group jid.
1884
+ */
1885
+ function createTargetableGroupAction(action) {
1886
+ const executeCurrent = async () => {
1887
+ requireChat();
1888
+ return action(chatJid);
1889
+ };
1890
+ return {
1891
+ async to(targetJid) {
1892
+ await getGroup(targetJid);
1893
+ return action(targetJid);
1894
+ },
1895
+ then(onfulfilled, onrejected) {
1896
+ return executeCurrent().then(onfulfilled, onrejected);
1897
+ },
1898
+ catch(onrejected) {
1899
+ return executeCurrent().catch(onrejected);
1900
+ },
1901
+ finally(onfinally) {
1902
+ return executeCurrent().finally(onfinally);
1903
+ },
1904
+ };
1905
+ }
1906
+ function isSelf(u) {
1907
+ const n = normalizeJid(u);
1908
+ return (botJid && n === botJid) || (botLid && n === botLid) || false;
1909
+ }
1821
1910
  return {
1822
1911
  /** @param {string|string[]} memberIds — JID (@s.whatsapp.net/@lid), this framework's @c.us form, or a bare phone number */
1823
1912
  add(memberIds) {
1824
1913
  return createTargetableAction((jid, users) => runParticipantsUpdate(jid, users, "add"), memberIds, "newMember");
1825
1914
  },
1826
1915
  /** @param {string|string[]} memberIds — JID (@s.whatsapp.net/@lid), this framework's @c.us form, or a bare phone number */
1827
- async kick(memberIds) {
1828
- requireChat();
1829
- const users = await resolveTargets(chatJid, Array.isArray(memberIds) ? memberIds : [memberIds]);
1830
- if (users.some((u) => {
1831
- const n = normalizeJid(u);
1832
- return (botJid && n === botJid) || (botLid && n === botLid);
1833
- })) {
1834
- throw new Error(t("driver.cannotKickSelf"));
1835
- }
1836
- return runParticipantsUpdate(chatJid, users, "remove");
1916
+ kick(memberIds) {
1917
+ return createTargetableAction(async (jid, users) => {
1918
+ if (users.some(isSelf))
1919
+ throw new Error(t("driver.cannotKickSelf"));
1920
+ return runParticipantsUpdate(jid, users, "remove");
1921
+ }, memberIds);
1837
1922
  },
1838
1923
  /** @param {string|string[]} memberIds — JID (@s.whatsapp.net/@lid), this framework's @c.us form, or a bare phone number */
1839
- async promote(memberIds) {
1840
- requireChat();
1841
- const users = await resolveTargets(chatJid, Array.isArray(memberIds) ? memberIds : [memberIds]);
1842
- return runParticipantsUpdate(chatJid, users, "promote");
1924
+ promote(memberIds) {
1925
+ return createTargetableAction((jid, users) => runParticipantsUpdate(jid, users, "promote"), memberIds);
1843
1926
  },
1844
1927
  /** @param {string|string[]} memberIds — JID (@s.whatsapp.net/@lid), this framework's @c.us form, or a bare phone number */
1845
- async demote(memberIds) {
1846
- requireChat();
1847
- const users = await resolveTargets(chatJid, Array.isArray(memberIds) ? memberIds : [memberIds]);
1848
- return runParticipantsUpdate(chatJid, users, "demote");
1928
+ demote(memberIds) {
1929
+ return createTargetableAction((jid, users) => runParticipantsUpdate(jid, users, "demote"), memberIds);
1849
1930
  },
1850
1931
  /** @param {string} name */
1851
- async setSubject(name) {
1852
- requireChat();
1853
- return contract.groupUpdateSubject(chatJid, name);
1932
+ setSubject(name) {
1933
+ return createTargetableGroupAction((jid) => contract.groupUpdateSubject(jid, name));
1854
1934
  },
1855
1935
  /** @param {string} text */
1856
- async setDescription(text) {
1857
- requireChat();
1858
- return contract.groupUpdateDescription(chatJid, text);
1936
+ setDescription(text) {
1937
+ return createTargetableGroupAction((jid) => contract.groupUpdateDescription(jid, text));
1859
1938
  },
1860
1939
  /** @param {string|Buffer} source */
1861
- async setProfilePic(source) {
1862
- requireChat();
1940
+ setProfilePic(source) {
1863
1941
  const buffer = Buffer.isBuffer(source) ? source : readFileSync(source);
1864
- return contract.updateProfilePicture(chatJid, buffer);
1942
+ return createTargetableGroupAction((jid) => contract.updateProfilePicture(jid, buffer));
1865
1943
  },
1866
1944
  async getInviteLink(groupId) {
1867
1945
  const jid = groupId ?? chatJid;
@@ -1870,9 +1948,8 @@ function buildAdminApi(contract, store, chatJid) {
1870
1948
  const code = await contract.groupInviteCode(jid);
1871
1949
  return `https://chat.whatsapp.com/${code}`;
1872
1950
  },
1873
- async revokeInvite() {
1874
- requireChat();
1875
- return contract.groupRevokeInvite(chatJid);
1951
+ revokeInvite() {
1952
+ return createTargetableGroupAction((jid) => contract.groupRevokeInvite(jid));
1876
1953
  },
1877
1954
  };
1878
1955
  }
@@ -25,6 +25,10 @@ function createMockContract() {
25
25
  deletes: [],
26
26
  blockUpdates: [],
27
27
  groupParticipantUpdates: [],
28
+ subjectUpdates: [],
29
+ descriptionUpdates: [],
30
+ profilePicUpdates: [],
31
+ revokeInvites: [],
28
32
  nameUpdates: [],
29
33
  statusUpdates: [],
30
34
  };
@@ -189,11 +193,20 @@ function createMockContract() {
189
193
  calls.groupParticipantUpdates.push({ jid, users, action });
190
194
  return users.map((u) => ({ status: "200", jid: u }));
191
195
  },
192
- groupUpdateSubject: async () => { },
193
- groupUpdateDescription: async () => { },
196
+ groupUpdateSubject: async (jid, subject) => {
197
+ calls.subjectUpdates.push({ jid, subject });
198
+ },
199
+ groupUpdateDescription: async (jid, description) => {
200
+ calls.descriptionUpdates.push({ jid, description });
201
+ },
194
202
  groupInviteCode: async () => "mock-invite-code-123",
195
- groupRevokeInvite: async () => "mock-new-invite-code-456",
196
- updateProfilePicture: async () => { },
203
+ groupRevokeInvite: async (jid) => {
204
+ calls.revokeInvites.push(jid);
205
+ return "mock-new-invite-code-456";
206
+ },
207
+ updateProfilePicture: async (jid) => {
208
+ calls.profilePicUpdates.push({ jid });
209
+ },
197
210
  updateProfileName: async (name) => {
198
211
  calls.nameUpdates.push(name);
199
212
  },
@@ -301,6 +314,44 @@ describe("kernel/pluginApi — buildSetupApi with Mock WaContract", () => {
301
314
  assert.equal(calls.groupParticipantUpdates[0].action, "add");
302
315
  assert.equal(calls.groupParticipantUpdates[0].jid, "120363000000000@g.us");
303
316
  });
317
+ test("setup admin.kick/promote/demote().to() target the explicit group", async () => {
318
+ const ctx = buildSetupApi(mockContract, store, pluginRegistry, "test_plugin");
319
+ const groupJid = "120363000000000@g.us";
320
+ await ctx.admin.promote("5516777777777@s.whatsapp.net").to(groupJid);
321
+ assert.equal(calls.groupParticipantUpdates.at(-1)?.action, "promote");
322
+ assert.equal(calls.groupParticipantUpdates.at(-1)?.jid, groupJid);
323
+ await ctx.admin.demote("5516777777777@s.whatsapp.net").to(groupJid);
324
+ assert.equal(calls.groupParticipantUpdates.at(-1)?.action, "demote");
325
+ assert.equal(calls.groupParticipantUpdates.at(-1)?.jid, groupJid);
326
+ await ctx.admin.kick("5516777777777@s.whatsapp.net").to(groupJid);
327
+ assert.equal(calls.groupParticipantUpdates.at(-1)?.action, "remove");
328
+ assert.equal(calls.groupParticipantUpdates.at(-1)?.jid, groupJid);
329
+ // self-kick guard still applies when targeting an explicit group
330
+ await assert.rejects(async () => { await ctx.admin.kick("5516999999999@s.whatsapp.net").to(groupJid); });
331
+ });
332
+ test("setup admin.setSubject/setDescription/setProfilePic/revokeInvite().to() target the explicit group", async () => {
333
+ const ctx = buildSetupApi(mockContract, store, pluginRegistry, "test_plugin");
334
+ const groupJid = "120363000000000@g.us";
335
+ await ctx.admin.setSubject("New Subject").to(groupJid);
336
+ assert.deepEqual(calls.subjectUpdates, [{ jid: groupJid, subject: "New Subject" }]);
337
+ await ctx.admin.setDescription("New Description").to(groupJid);
338
+ assert.deepEqual(calls.descriptionUpdates, [{ jid: groupJid, description: "New Description" }]);
339
+ await ctx.admin.setProfilePic(Buffer.from("pic-data")).to(groupJid);
340
+ assert.deepEqual(calls.profilePicUpdates, [{ jid: groupJid }]);
341
+ const invite = await ctx.admin.revokeInvite().to(groupJid);
342
+ assert.match(String(invite), /mock-new-invite-code-456/);
343
+ assert.deepEqual(calls.revokeInvites, [groupJid]);
344
+ });
345
+ test("setup admin.* without .to() throws (no current chat bound)", async () => {
346
+ const ctx = buildSetupApi(mockContract, store, pluginRegistry, "test_plugin");
347
+ await assert.rejects(async () => { await ctx.admin.kick("5516777777777@s.whatsapp.net"); }, /runtime group context/);
348
+ await assert.rejects(async () => { await ctx.admin.promote("5516777777777@s.whatsapp.net"); }, /runtime group context/);
349
+ await assert.rejects(async () => { await ctx.admin.demote("5516777777777@s.whatsapp.net"); }, /runtime group context/);
350
+ await assert.rejects(async () => { await ctx.admin.setSubject("X"); }, /runtime group context/);
351
+ await assert.rejects(async () => { await ctx.admin.setDescription("X"); }, /runtime group context/);
352
+ await assert.rejects(async () => { await ctx.admin.setProfilePic(Buffer.from("x")); }, /runtime group context/);
353
+ await assert.rejects(async () => { await ctx.admin.revokeInvite(); }, /runtime group context/);
354
+ });
304
355
  });
305
356
  describe("kernel/pluginApi — buildApi (Runtime) with Mock WaContract", () => {
306
357
  let store;
@@ -371,7 +422,7 @@ describe("kernel/pluginApi — buildApi (Runtime) with Mock WaContract", () => {
371
422
  await ctx.admin.kick("5516777777777@s.whatsapp.net");
372
423
  assert.equal(calls.groupParticipantUpdates.length, 2);
373
424
  assert.equal(calls.groupParticipantUpdates[1].action, "remove");
374
- await assert.rejects(() => ctx.admin.kick("5516999999999@s.whatsapp.net"));
425
+ await assert.rejects(async () => { await ctx.admin.kick("5516999999999@s.whatsapp.net"); });
375
426
  assert.equal(calls.groupParticipantUpdates.length, 2);
376
427
  const inviteLink = await ctx.admin.getInviteLink();
377
428
  assert.match(inviteLink, /chat\.whatsapp\.com\/mock-invite-code-123/);
package/package.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "name": "SyntaxError!",
6
6
  "email": "me@stxerr.dev"
7
7
  },
8
- "version": "5.9.1",
8
+ "version": "5.10.0",
9
9
  "license": "GPL-3.0-only",
10
10
  "private": false,
11
11
  "engines": {