@wirecat/whatsapp-cli 0.1.0 → 0.3.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.
Files changed (102) hide show
  1. package/README.md +213 -94
  2. package/dist/adapter/adapter.d.ts +8 -4
  3. package/dist/adapter/adapter.d.ts.map +1 -1
  4. package/dist/adapter/adapter.js +349 -90
  5. package/dist/adapter/adapter.js.map +1 -1
  6. package/dist/adapter/channels.d.ts +43 -0
  7. package/dist/adapter/channels.d.ts.map +1 -0
  8. package/dist/adapter/channels.js +100 -0
  9. package/dist/adapter/channels.js.map +1 -0
  10. package/dist/adapter/checks.d.ts +2 -3
  11. package/dist/adapter/checks.d.ts.map +1 -1
  12. package/dist/adapter/checks.js +1 -2
  13. package/dist/adapter/checks.js.map +1 -1
  14. package/dist/adapter/errors.d.ts +0 -5
  15. package/dist/adapter/errors.d.ts.map +1 -1
  16. package/dist/adapter/errors.js +7 -9
  17. package/dist/adapter/errors.js.map +1 -1
  18. package/dist/adapter/groups.d.ts +5 -1
  19. package/dist/adapter/groups.d.ts.map +1 -1
  20. package/dist/adapter/groups.js +51 -9
  21. package/dist/adapter/groups.js.map +1 -1
  22. package/dist/adapter/map.d.ts +17 -2
  23. package/dist/adapter/map.d.ts.map +1 -1
  24. package/dist/adapter/map.js +68 -7
  25. package/dist/adapter/map.js.map +1 -1
  26. package/dist/adapter/pins.d.ts +11 -0
  27. package/dist/adapter/pins.d.ts.map +1 -0
  28. package/dist/adapter/pins.js +10 -0
  29. package/dist/adapter/pins.js.map +1 -0
  30. package/dist/adapter/polls.d.ts +1 -1
  31. package/dist/adapter/polls.d.ts.map +1 -1
  32. package/dist/adapter/polls.js +2 -2
  33. package/dist/adapter/polls.js.map +1 -1
  34. package/dist/adapter/privacy.d.ts +18 -0
  35. package/dist/adapter/privacy.d.ts.map +1 -0
  36. package/dist/adapter/privacy.js +94 -0
  37. package/dist/adapter/privacy.js.map +1 -0
  38. package/dist/adapter/settings.d.ts +21 -0
  39. package/dist/adapter/settings.d.ts.map +1 -0
  40. package/dist/adapter/settings.js +76 -0
  41. package/dist/adapter/settings.js.map +1 -0
  42. package/dist/bin/wa.js +0 -13
  43. package/dist/bin/wa.js.map +1 -1
  44. package/dist/commands/session.d.ts +18 -7
  45. package/dist/commands/session.d.ts.map +1 -1
  46. package/dist/commands/session.js +92 -93
  47. package/dist/commands/session.js.map +1 -1
  48. package/dist/commands/setup.d.ts.map +1 -1
  49. package/dist/commands/setup.js +77 -129
  50. package/dist/commands/setup.js.map +1 -1
  51. package/dist/commands/update.d.ts.map +1 -1
  52. package/dist/commands/update.js +3 -22
  53. package/dist/commands/update.js.map +1 -1
  54. package/dist/engine/channels.d.ts +109 -0
  55. package/dist/engine/channels.d.ts.map +1 -0
  56. package/dist/engine/channels.js +29 -0
  57. package/dist/engine/channels.js.map +1 -0
  58. package/dist/engine/contract.d.ts +617 -43
  59. package/dist/engine/contract.d.ts.map +1 -1
  60. package/dist/engine/contract.js +226 -11
  61. package/dist/engine/contract.js.map +1 -1
  62. package/dist/engine/go-engine.d.ts.map +1 -1
  63. package/dist/engine/go-engine.js +23 -34
  64. package/dist/engine/go-engine.js.map +1 -1
  65. package/dist/engine/groups.d.ts +32 -14
  66. package/dist/engine/groups.d.ts.map +1 -1
  67. package/dist/engine/groups.js +2 -0
  68. package/dist/engine/groups.js.map +1 -1
  69. package/dist/engine/privacy.d.ts +33 -0
  70. package/dist/engine/privacy.d.ts.map +1 -0
  71. package/dist/engine/privacy.js +23 -0
  72. package/dist/engine/privacy.js.map +1 -0
  73. package/dist/engine/settings.d.ts +45 -0
  74. package/dist/engine/settings.d.ts.map +1 -0
  75. package/dist/engine/settings.js +34 -0
  76. package/dist/engine/settings.js.map +1 -0
  77. package/dist/install/postinstall.d.ts.map +1 -1
  78. package/dist/install/postinstall.js +4 -3
  79. package/dist/install/postinstall.js.map +1 -1
  80. package/dist/messenger.d.ts +3 -4
  81. package/dist/messenger.d.ts.map +1 -1
  82. package/dist/messenger.js +31 -14
  83. package/dist/messenger.js.map +1 -1
  84. package/dist/program.d.ts +5 -1
  85. package/dist/program.d.ts.map +1 -1
  86. package/dist/program.js +10 -38
  87. package/dist/program.js.map +1 -1
  88. package/dist/reasons.d.ts.map +1 -1
  89. package/dist/reasons.js +8 -0
  90. package/dist/reasons.js.map +1 -1
  91. package/dist/update.d.ts +0 -1
  92. package/dist/update.d.ts.map +1 -1
  93. package/dist/update.js +0 -7
  94. package/dist/update.js.map +1 -1
  95. package/dist/version.d.ts +1 -1
  96. package/dist/version.js +1 -1
  97. package/package.json +10 -10
  98. package/skills/whatsapp-cli/SKILL.md +59 -24
  99. package/dist/commands/answer.d.ts +0 -7
  100. package/dist/commands/answer.d.ts.map +0 -1
  101. package/dist/commands/answer.js +0 -11
  102. package/dist/commands/answer.js.map +0 -1
@@ -4,14 +4,17 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises";
4
4
  import { tmpdir } from "node:os";
5
5
  import { resolve as absolute, extname, join } from "node:path";
6
6
  import { CliError } from "@wirecat/cli-base";
7
- import { parseMarkdownSpans, } from "@wirecat/cli-core";
7
+ import { bareCheck, parseMarkdownSpans, pickChat, } from "@wirecat/cli-core";
8
8
  import { EngineError, OLDER_MAX, } from "../engine/contract.js";
9
- import { bareCheck } from "./checks.js";
9
+ import { channelAdapter, channelHistory, channelWrites, isChannel, refuseChannelPost } from "./channels.js";
10
10
  import { fromEngine } from "./errors.js";
11
11
  import { toWhatsApp } from "./format-markdown.js";
12
12
  import { groupAdapter } from "./groups.js";
13
- import { toChat, toMember, toMessage, toWaAttachment } from "./map.js";
13
+ import { isFile, sameFile, toAccount, toChat, toChatEvent, toChatUpdate, toMember, toMessage, toPersonReaction, toWaAttachment, } from "./map.js";
14
+ import { pinAdapter } from "./pins.js";
14
15
  import { pollAdapter, toHistoryVote, toVoteEvent } from "./polls.js";
16
+ import { privacyAdapter } from "./privacy.js";
17
+ import { AFTER_EVERY_MESSAGE, settingsAdapter, settingsWrite } from "./settings.js";
15
18
  // WhatsApp addresses a message by its sender and by whether it is the account's own, and the engine keeps no
16
19
  // messages, so an edit, a deletion, a reaction or a read receipt looks the message up in cli-core's store first.
17
20
  const NOTHING_STORED = {
@@ -35,24 +38,56 @@ const translated = async (work) => {
35
38
  throw error;
36
39
  }
37
40
  };
38
- const hit = (chat, message) => ({ ...message, chatTitle: chat?.name ?? null });
41
+ const hit = (message) => ({ ...message, chatTitle: null });
42
+ const PHONE_CHAT = /^\d+@s\.whatsapp\.net$/;
39
43
  const ENDED = {
40
44
  logged_out: (words) => fromEngine(new EngineError("authentication_error", `WhatsApp logged this device out — log in again with ${words("session.start")}`, "device.logged-out")),
41
45
  replaced: () => fromEngine(new EngineError("provider_unavailable", "another process took over this WhatsApp login; only one may hold it", "device.replaced")),
42
- banned: () => fromEngine(new EngineError("permission_error", "WhatsApp has banned or restricted this account; see the phone", "account.banned")),
46
+ banned: (_words, until) => fromEngine(new EngineError("permission_error", "WhatsApp has banned or restricted this account; see the phone", "account.banned", until)),
43
47
  outdated: () => fromEngine(new EngineError("provider_unavailable", "WhatsApp no longer accepts this version of wa; update it", "client.outdated")),
44
48
  stopped: () => fromEngine(new EngineError("provider_unavailable", "the WhatsApp engine stopped unexpectedly", "engine.stopped")),
45
49
  };
46
- /** Hands each event on until `signal` aborts; a connection WhatsApp ended rejects with why. */
47
- const listening = (engine, kinds, signal, words, onEvent) => {
50
+ const ENDING = new Set([
51
+ ...Object.values(ENDED).map((end) => end(bareCheck).details.reason),
52
+ "engine.stopped-mid-write",
53
+ ]);
54
+ /** A failure that means the connection is gone, not that one event was odd: it ends the listening. */
55
+ const ends = (error) => (error instanceof EngineError && ENDING.has(error.reason)) ||
56
+ (error instanceof CliError && ENDING.has(error.details.reason));
57
+ /**
58
+ * Hands each event on until `signal` aborts; a connection WhatsApp ended rejects with why, unless `expected` says this
59
+ * process asked for that end. Events are handled one at a time in arrival order. One whose handling fails is skipped
60
+ * and the next still runs: a stalled queue would keep every later push out of the store until a restart. The first skip
61
+ * is said on `warn` at once, kind and code only, never its content; `drained` settles when the handling caught up and
62
+ * then says how many were skipped if there were more.
63
+ */
64
+ const listening = (engine, kinds, signal, words, warn, handle, expected = () => false) => {
48
65
  let stop = () => { };
66
+ let queue = Promise.resolve();
67
+ let skipped = 0;
68
+ let fail = () => { };
69
+ const run = (kind, work) => {
70
+ queue = queue.then(work).catch((error) => fail(kind, error));
71
+ };
49
72
  const done = new Promise((resolve, reject) => {
73
+ fail = (kind, error) => {
74
+ if (ends(error))
75
+ return reject(error instanceof EngineError ? fromEngine(error) : error);
76
+ skipped += 1;
77
+ if (skipped > 1)
78
+ return;
79
+ const code = error instanceof CliError ? error.code : error instanceof Error ? error.name : "error";
80
+ warn(`a pushed ${kind} event could not be handled and was skipped (${code}); the ones after it go on, ` +
81
+ "and any more skips are counted when wa stops listening");
82
+ };
50
83
  const unlisten = engine.listen(kinds, (event) => {
51
- const ended = event.event === "connection" ? ENDED[event.state]?.(words) : undefined;
84
+ if (event.event === "connection" && expected(event.state))
85
+ return resolve();
86
+ const ended = event.event === "connection" ? ENDED[event.state]?.(words, event.until) : undefined;
52
87
  if (ended)
53
88
  reject(ended);
54
89
  else
55
- onEvent(event);
90
+ run(event.event, () => handle(event));
56
91
  });
57
92
  const aborted = () => resolve();
58
93
  signal.addEventListener("abort", aborted, { once: true });
@@ -63,8 +98,17 @@ const listening = (engine, kinds, signal, words, onEvent) => {
63
98
  if (signal.aborted)
64
99
  resolve();
65
100
  });
66
- return { done, stop };
101
+ // The caller may still be awaiting the engine's readiness when an ended connection rejects `done`.
102
+ done.catch(() => { });
103
+ const drained = () => queue.then(() => {
104
+ if (skipped > 1)
105
+ warn(`${skipped} pushed events could not be handled and were skipped in all`);
106
+ });
107
+ return { done, stop, drained, run };
67
108
  };
109
+ /** Re-keys that arrive this close together go to cli-core as one batch, up to this many. */
110
+ const REKEY_WINDOW_MS = 100;
111
+ const REKEY_BATCH = 500;
68
112
  const VIDEO = new Set([".mp4", ".m4v", ".mov"]);
69
113
  /** As tg sends them: a photo as a photo, a voice as a voice, a video as a video unless `--as-file`, the rest as files. */
70
114
  const uploadKind = ({ kind, name, asFile }) => kind === "photo" || kind === "voice" ? kind : !asFile && VIDEO.has(extname(name).toLowerCase()) ? "video" : "document";
@@ -87,6 +131,12 @@ const onDisk = async (source, work) => {
87
131
  return work(path);
88
132
  });
89
133
  };
134
+ const allOnDisk = async (files, work, placed = []) => {
135
+ const [next, ...rest] = files;
136
+ if (!next)
137
+ return work(placed);
138
+ return onDisk(next.upload.source, (path) => allOnDisk(rest, work, [...placed, { ...next, path }]));
139
+ };
90
140
  const quoted = (message) => ({
91
141
  id: message.id,
92
142
  senderId: message.senderId,
@@ -102,7 +152,9 @@ const keyOf = (chatId, message) => ({
102
152
  fromMe: message.outgoing === true,
103
153
  sender: message.senderId,
104
154
  });
105
- export const whatsAppAdapter = (engine, stored = NOTHING_STORED, words = bareCheck) => {
155
+ export const whatsAppAdapter = (engine, stored = NOTHING_STORED, words = bareCheck,
156
+ /** Where a skipped pushed event is said — a warning, so `--quiet` drops it. The diagnostic stream if not given. */
157
+ warn = (line) => process.stderr.write(`${line}\n`)) => {
106
158
  // A guessed key would make WhatsApp drop the change without a word, so a message the store lacks is refused.
107
159
  const held = async (chatId, messageId) => {
108
160
  const message = await stored.message(chatId, messageId);
@@ -110,46 +162,209 @@ export const whatsAppAdapter = (engine, stored = NOTHING_STORED, words = bareChe
110
162
  throw new CliError("not_found", `that message is not in the local store, and WhatsApp needs its sender to address it; ${words("serve")} keeps the store filled`);
111
163
  return message;
112
164
  };
113
- async function* fetched(attachment) {
165
+ // Set by `session end`: WhatsApp then ends the connection as logged out, which is the answer, not a failure.
166
+ let loggingOut = false;
167
+ // Held re-keys of a running `feed`: a live event may name the new id, and must not reach cli-core before them (§5).
168
+ const holding = new Set();
169
+ const handingOn = (onEvent, signal, expected) => {
170
+ const handed = async (event) => {
171
+ for (const handOn of holding)
172
+ handOn();
173
+ if (event.event === "message")
174
+ onEvent({ event: "message", message: hit(toMessage(event.message)) });
175
+ else if (event.event === "edit")
176
+ onEvent({ event: "edit", message: hit(toMessage(event.message)) });
177
+ else if (event.event === "delete")
178
+ onEvent({
179
+ event: "delete",
180
+ chatId: event.chat,
181
+ chatTitle: null,
182
+ messageId: event.id,
183
+ });
184
+ else if (event.event === "star")
185
+ onEvent({
186
+ event: "star",
187
+ chatId: event.chat,
188
+ chatTitle: null,
189
+ messageId: event.id,
190
+ starred: event.starred,
191
+ });
192
+ else if (event.event === "reaction")
193
+ onEvent({
194
+ event: "person_reaction",
195
+ chatId: event.reaction.chat,
196
+ chatTitle: null,
197
+ messageId: event.reaction.message,
198
+ reaction: toPersonReaction(event.reaction),
199
+ });
200
+ else if (event.event === "receipt")
201
+ onEvent({
202
+ event: "delivery",
203
+ chatId: event.receipt.chat,
204
+ chatTitle: null,
205
+ messageId: event.receipt.id,
206
+ delivery: event.receipt.delivery,
207
+ at: event.receipt.at,
208
+ });
209
+ else if (event.event === "vote") {
210
+ const vote = await toVoteEvent(event.vote, stored);
211
+ if (vote)
212
+ onEvent(vote);
213
+ }
214
+ else if (event.event === "chat_event")
215
+ onEvent({
216
+ event: "chat_event",
217
+ chatId: event.change.chat,
218
+ chatTitle: null,
219
+ change: toChatEvent(event.change),
220
+ });
221
+ };
222
+ // In arrival order: a vote reads its poll from the store, and nothing after it may overtake it.
223
+ const live = listening(engine, ["message", "edit", "delete", "vote", "reaction", "star", "receipt", "chat_event", "connection"], signal, words, warn, handed, expected);
224
+ return {
225
+ done: live.done,
226
+ stop: async () => {
227
+ live.stop();
228
+ await live.drained();
229
+ },
230
+ };
231
+ };
232
+ // A push after a re-link carries the path the file had then, and the store may hold the one the phone uploaded
233
+ // again. cli-core keeps a stored ref only when the pushed file has none, so the pushed one is left off when it names
234
+ // the same file; another file keeps its own. Only a message with a ref costs a store read; one that fails hands the
235
+ // message on as pushed, never loses it.
236
+ const withoutKeptRefs = async (message) => {
237
+ if (!message.attachments.some((file) => file.providerRef))
238
+ return message;
239
+ const kept = await stored.message(message.chatId, message.id).catch(() => undefined);
240
+ if (!kept?.attachments.some((file) => file.providerRef))
241
+ return message;
242
+ return {
243
+ ...message,
244
+ attachments: message.attachments.map((file, position) => {
245
+ if (!sameFile(file.providerRef, kept.attachments[position]?.providerRef))
246
+ return file;
247
+ const { providerRef: _pushed, ...rest } = file;
248
+ return rest;
249
+ }),
250
+ };
251
+ };
252
+ const pushing = async (onBatch, signal, expected) => {
253
+ // cli-core saves each batch's re-keys apart, so thousands at a serve's start must not come one batch each; a push
254
+ // hands on the held ones before its own, as they arrived.
255
+ let held = [];
256
+ let due;
257
+ const flush = () => {
258
+ clearTimeout(due);
259
+ due = undefined;
260
+ if (held.length === 0)
261
+ return;
262
+ const rekeys = held;
263
+ held = [];
264
+ onBatch({ rekeys });
265
+ };
266
+ // In arrival order: a push waits for its store reads, and a re-key after it must not overtake it.
267
+ const handed = async (event) => {
268
+ if (event.event === "rekey") {
269
+ held.push({ from: event.from, to: event.to });
270
+ if (held.length >= REKEY_BATCH)
271
+ flush();
272
+ else
273
+ due ??= setTimeout(() => pushes.run("rekey", flush), REKEY_WINDOW_MS);
274
+ return;
275
+ }
276
+ if (event.event !== "history")
277
+ return;
278
+ flush();
279
+ const { rekeys, chats, contacts, messages, votes, reactions, events } = event.history;
280
+ onBatch({
281
+ rekeys: rekeys ?? [],
282
+ chats: chats.map(toChatUpdate),
283
+ people: contacts.map(toMember),
284
+ messages: await Promise.all(messages.map((message) => withoutKeptRefs(toMessage(message)))),
285
+ votes: votes.flatMap((vote) => toHistoryVote(vote) ?? []),
286
+ reactions: (reactions ?? []).map((reaction) => ({
287
+ chatId: reaction.chat,
288
+ messageId: reaction.message,
289
+ reaction: toPersonReaction(reaction),
290
+ })),
291
+ events: (events ?? []).map((change) => ({ chatId: change.chat, change: toChatEvent(change) })),
292
+ });
293
+ };
294
+ const pushes = listening(engine, ["history", "rekey", "connection"], signal, words, warn, handed, expected);
295
+ const handOn = () => {
296
+ try {
297
+ flush();
298
+ }
299
+ catch (error) {
300
+ pushes.run("rekey", () => Promise.reject(error));
301
+ }
302
+ };
303
+ holding.add(handOn);
304
+ try {
305
+ await pushes.done;
306
+ }
307
+ finally {
308
+ holding.delete(handOn);
309
+ pushes.stop();
310
+ pushes.run("rekey", flush);
311
+ await pushes.drained();
312
+ }
313
+ };
314
+ const ownLogout = (state) => state === "logged_out" && loggingOut;
315
+ // A deletion for the account alone, a star and a chat marked read are changes to its synced settings, not messages.
316
+ const synced = settingsWrite(translated, words);
317
+ // The phone's new upload replaces the whole stored ref on `file`, so cli-core saves it and a later download or a
318
+ // forward uses the live path; a second read of the same file in this run uses it too.
319
+ async function* fetched(file, attachment, key) {
114
320
  const folder = await mkdtemp(join(tmpdir(), "wa-media-"));
115
321
  try {
116
322
  const path = join(folder, "file");
117
- await translated(engine.download(attachment, path));
323
+ const renewed = await translated(engine.download(attachment, path, key));
324
+ if (renewed) {
325
+ // The old URL names the dead copy; without one, the engine builds it from the new path.
326
+ const { url: _dead, ...kept } = attachment.ref;
327
+ attachment.ref = { ...kept, ...renewed };
328
+ file.providerRef = attachment.ref;
329
+ }
118
330
  yield* createReadStream(path);
119
331
  }
120
332
  finally {
121
333
  await rm(folder, { recursive: true, force: true });
122
334
  }
123
335
  }
124
- const titled = async () => new Map((await translated(engine.directory())).map((chat) => [chat.jid, chat]));
125
336
  const core = {
126
337
  formatMarkdown: async (text) => parseMarkdownSpans(text),
127
338
  self: () => engine.self(),
128
339
  newSendId: () => `3EB0${randomBytes(9).toString("hex").toUpperCase()}`,
129
- me: async () => {
130
- const account = await translated(engine.me());
131
- return { id: account.jid, name: account.name, username: null };
132
- },
340
+ me: async () => toAccount(await translated(engine.me())),
341
+ // A send to a phone-form chat lands under the LID the engine answers, so every later command must address that
342
+ // chat too, or a delete, a star or a vote by the typed number misses the stored message (§5).
133
343
  resolve: async (query) => {
134
- const chats = await titled();
135
- const byId = chats.get(query);
136
- if (byId)
137
- return toChat(byId);
138
344
  if (query.includes("@"))
139
345
  return {
140
- id: query,
346
+ id: PHONE_CHAT.test(query) ? await translated(engine.canonicalChat(query)) : query,
141
347
  title: null,
142
348
  kind: "unknown",
143
349
  unreadCount: null,
144
350
  lastMessageAt: null,
145
351
  participantsCount: null,
146
352
  };
147
- const named = [...chats.values()].filter((chat) => chat.name === query);
148
- if (named.length === 1 && named[0])
149
- return toChat(named[0]);
150
- if (named.length > 1)
151
- throw new CliError("validation_error", `${named.length} chats share that name — name one by its id; ${words("chats.list")} shows them`);
152
- throw new CliError("not_found", `no chat by that name or id; ${words("chats.list")} shows the chats this profile has`);
353
+ const directory = (await translated(engine.directory())).map(toChat);
354
+ try {
355
+ return pickChat(query, directory);
356
+ }
357
+ catch (error) {
358
+ if (!(error instanceof CliError))
359
+ throw error;
360
+ // cli-core's refusal quotes the typed name and the matching titles; an error here names neither (WC-SECR-6).
361
+ const candidates = error.details.candidates;
362
+ if (error.code === "validation_error" && Array.isArray(candidates))
363
+ throw new CliError("validation_error", `${candidates.length} chats match that name — name one by its id; ${words("chats.list")} shows them`);
364
+ if (error.code !== "not_found")
365
+ throw error;
366
+ throw new CliError("not_found", `no chat by that name or id; ${words("chats.list")} shows the chats this profile has`);
367
+ }
153
368
  },
154
369
  chat: async (jid) => {
155
370
  const card = await translated(engine.chat(jid));
@@ -159,11 +374,22 @@ export const whatsAppAdapter = (engine, stored = NOTHING_STORED, words = bareChe
159
374
  const refused = REFUSED.filter((option) => options[option] !== undefined && options[option] !== false);
160
375
  if (refused.length > 0)
161
376
  throw new CliError("validation_error", `WhatsApp cannot send with ${refused.join(", ")}`);
162
- const [upload, ...more] = options.attachments ?? [];
163
- if (more.length > 0)
164
- throw new CliError("validation_error", "WhatsApp sends one file per message here");
377
+ const uploads = options.attachments ?? [];
378
+ const [upload] = uploads;
379
+ const files = uploads.map((one) => ({ upload: one, kind: uploadKind(one) }));
380
+ if (files.length > 1 && files.some(({ kind }) => kind !== "photo" && kind !== "video"))
381
+ throw new CliError("validation_error", "a WhatsApp album holds photos and videos only; send a document in a message of its own");
165
382
  if (upload?.kind === "voice" && text.trim() !== "")
166
383
  throw new CliError("validation_error", "WhatsApp sends a voice message alone, without text");
384
+ const { stickerFile } = options;
385
+ // Untried on a live channel, where a post carries a URL and no key.
386
+ if (stickerFile && isChannel(chatId))
387
+ throw new CliError("validation_error", "wa does not post stickers to a WhatsApp channel yet");
388
+ if (isChannel(chatId))
389
+ await refuseChannelPost(engine, translated, chatId, {
390
+ files: files.length,
391
+ reply: options.replyTo !== undefined,
392
+ });
167
393
  // A reply to a message the store lacks still goes, with its id alone.
168
394
  const answered = options.replyTo === undefined ? undefined : await stored.message(chatId, options.replyTo);
169
395
  const request = {
@@ -175,9 +401,16 @@ export const whatsAppAdapter = (engine, stored = NOTHING_STORED, words = bareChe
175
401
  ? { quoted: { fromMe: answered.outgoing === true, sender: answered.senderId, text: answered.text } }
176
402
  : {}),
177
403
  };
178
- const message = await translated(upload
179
- ? onDisk(upload.source, (path) => engine.sendMedia({ ...request, file: { kind: uploadKind(upload), path, name: upload.name } }))
180
- : engine.send(request));
404
+ const message = await translated(files.length > 1
405
+ ? allOnDisk(files, (placed) => engine.sendAlbum({
406
+ ...request,
407
+ files: placed.map(({ upload, kind, path }) => ({ kind, path, name: upload.name })),
408
+ }))
409
+ : upload
410
+ ? onDisk(upload.source, (path) => engine.sendMedia({ ...request, file: { kind: uploadKind(upload), path, name: upload.name } }))
411
+ : stickerFile
412
+ ? onDisk(stickerFile.source, (path) => engine.sendMedia({ ...request, file: { kind: "sticker", path, name: stickerFile.name } }))
413
+ : engine.send(request));
181
414
  return { message: toMessage(message), sendId: options.sendId };
182
415
  },
183
416
  forward: async (fromChatId, messageId, toChatId, { sendId, threadId }) => {
@@ -190,11 +423,16 @@ export const whatsAppAdapter = (engine, stored = NOTHING_STORED, words = bareChe
190
423
  throw new CliError("validation_error", "the local store does not hold this message's content whole, so it cannot be forwarded; forward it on the phone");
191
424
  if (!attachment && original.text === "")
192
425
  throw new CliError("validation_error", "that message has nothing to forward");
426
+ // A channel's files carry no key, and a chat's recipient cannot open a file without one.
427
+ if (isChannel(fromChatId) && attachment?.ref && "directPath" in attachment.ref)
428
+ throw new CliError("validation_error", "a WhatsApp channel's file is not encrypted, so a forward would arrive as a file that does not open; " +
429
+ "save it on the phone and send it as a new file", { reason: "media.channel-file-forward" });
193
430
  const copy = await translated(engine.forward({ chat: toChatId, id: sendId, text: original.text, attachment: attachment ?? null }));
194
431
  return { ...toMessage(copy), forwardedFrom: quoted(original) };
195
432
  },
196
433
  // From the keys in the store: WhatsApp has no request for an old message. A file that is not one (a place, a
197
- // contact card) or that the store has no keys for is named in `skipped`.
434
+ // contact card) or that the store has no keys for is named in `skipped`. The message's key goes along, so the
435
+ // engine can ask the phone for a file WhatsApp no longer keeps.
198
436
  download: async (chat, messageId) => {
199
437
  const { id: chatId } = await core.resolve(chat);
200
438
  const message = await held(chatId, messageId);
@@ -202,18 +440,19 @@ export const whatsAppAdapter = (engine, stored = NOTHING_STORED, words = bareChe
202
440
  const skipped = [];
203
441
  message.attachments.forEach((stored, position) => {
204
442
  const attachment = toWaAttachment(stored);
205
- if (!attachment?.ref) {
443
+ if (!isFile(attachment)) {
206
444
  skipped.push(stored.kind);
207
445
  return;
208
446
  }
209
- files.push({
447
+ const file = {
210
448
  kind: attachment.kind,
211
449
  ...(attachment.name === undefined ? {} : { name: attachment.name }),
212
450
  ...(attachment.mime === undefined ? {} : { mime: attachment.mime }),
213
451
  ...(attachment.size === undefined ? {} : { size: attachment.size }),
214
452
  position,
215
- bytes: () => fetched(attachment),
216
- });
453
+ bytes: () => fetched(file, attachment, keyOf(chatId, message)),
454
+ };
455
+ files.push(file);
217
456
  });
218
457
  return { files, skipped };
219
458
  },
@@ -229,20 +468,44 @@ export const whatsAppAdapter = (engine, stored = NOTHING_STORED, words = bareChe
229
468
  return { ...original, text: edited.text, editedAt: edited.editedAt };
230
469
  },
231
470
  delete: async (chatId, messageIds, { forEveryone }) => {
232
- if (!forEveryone)
233
- throw new CliError("validation_error", "wa deletes only for everyone so far; add --for-everyone, or delete it for you on the phone");
234
471
  const messages = await Promise.all(messageIds.map((id) => held(chatId, id)));
235
- for (const message of messages)
236
- await translated(engine.revoke(keyOf(chatId, message)));
472
+ const done = [];
473
+ for (const message of messages) {
474
+ try {
475
+ if (forEveryone)
476
+ await translated(engine.revoke(keyOf(chatId, message)));
477
+ else
478
+ await synced(engine.deleteForMe({ ...keyOf(chatId, message), timestamp: message.timestamp }));
479
+ }
480
+ catch (error) {
481
+ if (done.length === 0 || !(error instanceof CliError))
482
+ throw error;
483
+ // One request per message: the ones before this went, so the run as a whole is not unsent (WC-OUT-20).
484
+ const { sent: _, ...details } = error.details;
485
+ throw new CliError(error.code, `deleted ${done.join(", ")}; ${message.id} and any after it were not, or may not have been: ${error.message}`, details);
486
+ }
487
+ done.push(message.id);
488
+ }
237
489
  },
238
490
  react: async (chatId, messageId, emoji) => {
239
491
  const message = await held(chatId, messageId);
240
492
  await translated(engine.react(keyOf(chatId, message), emoji));
241
493
  },
242
- // One receipt, for the newest message from someone else up to `until`: the account's own messages have none.
494
+ star: async (chatId, messageId, starred) => {
495
+ const message = await held(chatId, messageId);
496
+ await synced(engine.star(keyOf(chatId, message), starred));
497
+ },
498
+ // The read patch takes a mark-unread off the owner's devices, which a receipt does not; it is bounded by `until`
499
+ // or the newest stored message, either side's, so it names nothing newer than the receipt covers. It goes first,
500
+ // so a refusal for missing settings keys leaves nothing sent. Then one receipt, for the newest message from
501
+ // someone else up to `until`: the account's own messages have none. A chat the store holds nothing of has neither.
243
502
  markRead: async (chatId, until) => {
244
- const anchor = until === undefined ? undefined : await held(chatId, until);
245
- const target = anchor && !anchor.outgoing ? anchor : await stored.newestIncoming(chatId, until);
503
+ const anchor = until === undefined ? await stored.anchor(chatId, AFTER_EVERY_MESSAGE) : await held(chatId, until);
504
+ if (!anchor)
505
+ return;
506
+ if (!isChannel(chatId) && !chatId.endsWith("@broadcast"))
507
+ await synced(engine.markChatRead({ ...keyOf(chatId, anchor), timestamp: anchor.timestamp }));
508
+ const target = until !== undefined && !anchor.outgoing ? anchor : await stored.newestIncoming(chatId, until);
246
509
  if (target)
247
510
  await translated(engine.markRead(keyOf(chatId, target)));
248
511
  },
@@ -251,6 +514,8 @@ export const whatsAppAdapter = (engine, stored = NOTHING_STORED, words = bareChe
251
514
  // page reached (`before` is a time, `fetching.orderBy: "time"`).
252
515
  history: async (chat, { limit, before }) => {
253
516
  const { id: chatId } = await core.resolve(chat);
517
+ if (isChannel(chatId))
518
+ return translated(channelHistory(engine, stored, chatId, { limit, ...(before === undefined ? {} : { before }) }));
254
519
  const anchor = await stored.anchor(chatId, before);
255
520
  if (!anchor)
256
521
  throw new CliError("not_found", "nothing of this chat is in the local store to count back from — the phone sends messages older than one " +
@@ -259,33 +524,13 @@ export const whatsAppAdapter = (engine, stored = NOTHING_STORED, words = bareChe
259
524
  const items = older.messages.map(toMessage).sort((one, other) => one.timestamp.localeCompare(other.timestamp));
260
525
  return { items, hasMore: older.more };
261
526
  },
262
- logout: () => translated(engine.logout()),
527
+ logout: () => {
528
+ loggingOut = true;
529
+ return translated(engine.logout());
530
+ },
263
531
  close: () => engine.close(),
264
532
  watch: async (onEvent, signal, onReady) => {
265
- const chats = await titled();
266
- const handed = async (event) => {
267
- if (event.event === "message")
268
- onEvent({ event: "message", message: hit(chats.get(event.message.chat), toMessage(event.message)) });
269
- else if (event.event === "edit")
270
- onEvent({ event: "edit", message: hit(chats.get(event.message.chat), toMessage(event.message)) });
271
- else if (event.event === "delete")
272
- onEvent({
273
- event: "delete",
274
- chatId: event.chat,
275
- chatTitle: chats.get(event.chat)?.name ?? null,
276
- messageId: event.id,
277
- });
278
- else if (event.event === "vote") {
279
- const vote = await toVoteEvent(event.vote, chats.get(event.vote.chat)?.name ?? null, stored);
280
- if (vote)
281
- onEvent(vote);
282
- }
283
- };
284
- // In arrival order: a vote reads its poll from the store, and nothing after it may overtake it.
285
- let queue = Promise.resolve();
286
- const live = listening(engine, ["message", "edit", "delete", "vote", "connection"], signal, words, (event) => {
287
- queue = queue.then(() => handed(event));
288
- });
533
+ const live = handingOn(onEvent, signal);
289
534
  try {
290
535
  await translated(engine.ready(signal));
291
536
  if (!signal.aborted)
@@ -293,34 +538,48 @@ export const whatsAppAdapter = (engine, stored = NOTHING_STORED, words = bareChe
293
538
  await live.done;
294
539
  }
295
540
  finally {
296
- live.stop();
297
- await queue;
541
+ await live.stop();
298
542
  }
299
543
  },
300
- feed: async (onBatch, signal) => {
301
- const pushes = listening(engine, ["history", "connection"], signal, words, (event) => {
302
- if (event.event !== "history")
303
- return;
304
- const { chats, contacts, messages, votes } = event.history;
305
- onBatch({
306
- chats: chats.map(toChat),
307
- people: contacts.map(toMember),
308
- messages: messages.map(toMessage),
309
- votes: votes.flatMap((vote) => toHistoryVote(vote) ?? []),
310
- });
311
- });
544
+ // What a one-shot connection receives: WhatsApp hands a queued message to a linked device once, so it is saved
545
+ // here or lost. Never connects; the command's own request does that.
546
+ listen: async (onEvent, signal) => {
547
+ const live = handingOn(onEvent, signal, ownLogout);
312
548
  try {
313
- await pushes.done;
549
+ await live.done;
314
550
  }
315
551
  finally {
316
- pushes.stop();
552
+ await live.stop();
317
553
  }
318
554
  },
555
+ // The history a one-shot connection is pushed, which WhatsApp likewise sends once. Never connects.
556
+ listenHistory: (onBatch, signal) => pushing(onBatch, signal, ownLogout),
557
+ feed: (onBatch, signal) => pushing(onBatch, signal),
558
+ // Only phone-form one-to-one chats can need a re-key, and in one request: cli-core waits at most 5 s for it.
559
+ storedChats: async (ids, recipients = []) => {
560
+ const phones = ids.filter((id) => PHONE_CHAT.test(id));
561
+ const typed = recipients.filter((id) => PHONE_CHAT.test(id));
562
+ if (phones.length > 0 || typed.length > 0)
563
+ await translated(engine.storedChats(phones, typed.length > 0 ? typed : undefined));
564
+ },
319
565
  };
566
+ const polls = pollAdapter({ engine, stored, held, keyOf, translated });
567
+ const groups = groupAdapter({
568
+ engine,
569
+ resolve: core.resolve,
570
+ translated,
571
+ wroteTo: (person) => stored.wroteTo(person),
572
+ onDisk,
573
+ });
320
574
  return {
321
575
  ...core,
322
- ...groupAdapter({ engine, resolve: core.resolve, translated, wroteTo: (person) => stored.wroteTo(person) }),
323
- ...pollAdapter({ engine, stored, held, keyOf, translated }),
576
+ ...groups,
577
+ ...channelAdapter({ engine, resolve: core.resolve, translated, groups }),
578
+ ...polls,
579
+ ...channelWrites({ ...core, ...polls }),
580
+ ...privacyAdapter({ engine, translated }),
581
+ ...pinAdapter({ engine, held, keyOf, translated }),
582
+ ...settingsAdapter({ engine, translated, words, stored, keyOf, onDisk }),
324
583
  };
325
584
  };
326
585
  //# sourceMappingURL=adapter.js.map