@wirecat/whatsapp-cli 0.1.0 → 0.2.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 +313 -85
  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 +16 -2
  23. package/dist/adapter/map.d.ts.map +1 -1
  24. package/dist/adapter/map.js +56 -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 +100 -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 +475 -43
  59. package/dist/engine/contract.d.ts.map +1 -1
  60. package/dist/engine/contract.js +178 -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 +26 -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 +29 -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, } 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, 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,41 +162,173 @@ 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 === "vote") {
193
+ const vote = await toVoteEvent(event.vote, stored);
194
+ if (vote)
195
+ onEvent(vote);
196
+ }
197
+ else if (event.event === "chat_event")
198
+ onEvent({
199
+ event: "chat_event",
200
+ chatId: event.change.chat,
201
+ chatTitle: null,
202
+ change: toChatEvent(event.change),
203
+ });
204
+ };
205
+ // In arrival order: a vote reads its poll from the store, and nothing after it may overtake it.
206
+ const live = listening(engine, ["message", "edit", "delete", "vote", "star", "chat_event", "connection"], signal, words, warn, handed, expected);
207
+ return {
208
+ done: live.done,
209
+ stop: async () => {
210
+ live.stop();
211
+ await live.drained();
212
+ },
213
+ };
214
+ };
215
+ // A push after a re-link carries the path the file had then, and the store may hold the one the phone uploaded
216
+ // again. cli-core keeps a stored ref only when the pushed file has none, so the pushed one is left off when it names
217
+ // the same file; another file keeps its own. Only a message with a ref costs a store read; one that fails hands the
218
+ // message on as pushed, never loses it.
219
+ const withoutKeptRefs = async (message) => {
220
+ if (!message.attachments.some((file) => file.providerRef))
221
+ return message;
222
+ const kept = await stored.message(message.chatId, message.id).catch(() => undefined);
223
+ if (!kept?.attachments.some((file) => file.providerRef))
224
+ return message;
225
+ return {
226
+ ...message,
227
+ attachments: message.attachments.map((file, position) => {
228
+ if (!sameFile(file.providerRef, kept.attachments[position]?.providerRef))
229
+ return file;
230
+ const { providerRef: _pushed, ...rest } = file;
231
+ return rest;
232
+ }),
233
+ };
234
+ };
235
+ const pushing = async (onBatch, signal, expected) => {
236
+ // cli-core saves each batch's re-keys apart, so thousands at a serve's start must not come one batch each; a push
237
+ // hands on the held ones before its own, as they arrived.
238
+ let held = [];
239
+ let due;
240
+ const flush = () => {
241
+ clearTimeout(due);
242
+ due = undefined;
243
+ if (held.length === 0)
244
+ return;
245
+ const rekeys = held;
246
+ held = [];
247
+ onBatch({ rekeys });
248
+ };
249
+ // In arrival order: a push waits for its store reads, and a re-key after it must not overtake it.
250
+ const handed = async (event) => {
251
+ if (event.event === "rekey") {
252
+ held.push({ from: event.from, to: event.to });
253
+ if (held.length >= REKEY_BATCH)
254
+ flush();
255
+ else
256
+ due ??= setTimeout(() => pushes.run("rekey", flush), REKEY_WINDOW_MS);
257
+ return;
258
+ }
259
+ if (event.event !== "history")
260
+ return;
261
+ flush();
262
+ const { rekeys, chats, contacts, messages, votes, events } = event.history;
263
+ onBatch({
264
+ rekeys: rekeys ?? [],
265
+ chats: chats.map(toChatUpdate),
266
+ people: contacts.map(toMember),
267
+ messages: await Promise.all(messages.map((message) => withoutKeptRefs(toMessage(message)))),
268
+ votes: votes.flatMap((vote) => toHistoryVote(vote) ?? []),
269
+ events: (events ?? []).map((change) => ({ chatId: change.chat, change: toChatEvent(change) })),
270
+ });
271
+ };
272
+ const pushes = listening(engine, ["history", "rekey", "connection"], signal, words, warn, handed, expected);
273
+ const handOn = () => {
274
+ try {
275
+ flush();
276
+ }
277
+ catch (error) {
278
+ pushes.run("rekey", () => Promise.reject(error));
279
+ }
280
+ };
281
+ holding.add(handOn);
282
+ try {
283
+ await pushes.done;
284
+ }
285
+ finally {
286
+ holding.delete(handOn);
287
+ pushes.stop();
288
+ pushes.run("rekey", flush);
289
+ await pushes.drained();
290
+ }
291
+ };
292
+ const ownLogout = (state) => state === "logged_out" && loggingOut;
293
+ // A deletion for the account alone, a star and a chat marked read are changes to its synced settings, not messages.
294
+ const synced = settingsWrite(translated, words);
295
+ // The phone's new upload replaces the whole stored ref on `file`, so cli-core saves it and a later download or a
296
+ // forward uses the live path; a second read of the same file in this run uses it too.
297
+ async function* fetched(file, attachment, key) {
114
298
  const folder = await mkdtemp(join(tmpdir(), "wa-media-"));
115
299
  try {
116
300
  const path = join(folder, "file");
117
- await translated(engine.download(attachment, path));
301
+ const renewed = await translated(engine.download(attachment, path, key));
302
+ if (renewed) {
303
+ // The old URL names the dead copy; without one, the engine builds it from the new path.
304
+ const { url: _dead, ...kept } = attachment.ref;
305
+ attachment.ref = { ...kept, ...renewed };
306
+ file.providerRef = attachment.ref;
307
+ }
118
308
  yield* createReadStream(path);
119
309
  }
120
310
  finally {
121
311
  await rm(folder, { recursive: true, force: true });
122
312
  }
123
313
  }
124
- const titled = async () => new Map((await translated(engine.directory())).map((chat) => [chat.jid, chat]));
125
314
  const core = {
126
315
  formatMarkdown: async (text) => parseMarkdownSpans(text),
127
316
  self: () => engine.self(),
128
317
  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
- },
318
+ me: async () => toAccount(await translated(engine.me())),
319
+ // A send to a phone-form chat lands under the LID the engine answers, so every later command must address that
320
+ // chat too, or a delete, a star or a vote by the typed number misses the stored message (§5).
133
321
  resolve: async (query) => {
134
- const chats = await titled();
135
- const byId = chats.get(query);
136
- if (byId)
137
- return toChat(byId);
138
322
  if (query.includes("@"))
139
323
  return {
140
- id: query,
324
+ id: PHONE_CHAT.test(query) ? await translated(engine.canonicalChat(query)) : query,
141
325
  title: null,
142
326
  kind: "unknown",
143
327
  unreadCount: null,
144
328
  lastMessageAt: null,
145
329
  participantsCount: null,
146
330
  };
147
- const named = [...chats.values()].filter((chat) => chat.name === query);
331
+ const named = (await translated(engine.directory())).filter((chat) => chat.name === query);
148
332
  if (named.length === 1 && named[0])
149
333
  return toChat(named[0]);
150
334
  if (named.length > 1)
@@ -159,11 +343,22 @@ export const whatsAppAdapter = (engine, stored = NOTHING_STORED, words = bareChe
159
343
  const refused = REFUSED.filter((option) => options[option] !== undefined && options[option] !== false);
160
344
  if (refused.length > 0)
161
345
  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");
346
+ const uploads = options.attachments ?? [];
347
+ const [upload] = uploads;
348
+ const files = uploads.map((one) => ({ upload: one, kind: uploadKind(one) }));
349
+ if (files.length > 1 && files.some(({ kind }) => kind !== "photo" && kind !== "video"))
350
+ throw new CliError("validation_error", "a WhatsApp album holds photos and videos only; send a document in a message of its own");
165
351
  if (upload?.kind === "voice" && text.trim() !== "")
166
352
  throw new CliError("validation_error", "WhatsApp sends a voice message alone, without text");
353
+ const { stickerFile } = options;
354
+ // Untried on a live channel, where a post carries a URL and no key.
355
+ if (stickerFile && isChannel(chatId))
356
+ throw new CliError("validation_error", "wa does not post stickers to a WhatsApp channel yet");
357
+ if (isChannel(chatId))
358
+ await refuseChannelPost(engine, translated, chatId, {
359
+ files: files.length,
360
+ reply: options.replyTo !== undefined,
361
+ });
167
362
  // A reply to a message the store lacks still goes, with its id alone.
168
363
  const answered = options.replyTo === undefined ? undefined : await stored.message(chatId, options.replyTo);
169
364
  const request = {
@@ -175,9 +370,16 @@ export const whatsAppAdapter = (engine, stored = NOTHING_STORED, words = bareChe
175
370
  ? { quoted: { fromMe: answered.outgoing === true, sender: answered.senderId, text: answered.text } }
176
371
  : {}),
177
372
  };
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));
373
+ const message = await translated(files.length > 1
374
+ ? allOnDisk(files, (placed) => engine.sendAlbum({
375
+ ...request,
376
+ files: placed.map(({ upload, kind, path }) => ({ kind, path, name: upload.name })),
377
+ }))
378
+ : upload
379
+ ? onDisk(upload.source, (path) => engine.sendMedia({ ...request, file: { kind: uploadKind(upload), path, name: upload.name } }))
380
+ : stickerFile
381
+ ? onDisk(stickerFile.source, (path) => engine.sendMedia({ ...request, file: { kind: "sticker", path, name: stickerFile.name } }))
382
+ : engine.send(request));
181
383
  return { message: toMessage(message), sendId: options.sendId };
182
384
  },
183
385
  forward: async (fromChatId, messageId, toChatId, { sendId, threadId }) => {
@@ -190,11 +392,16 @@ export const whatsAppAdapter = (engine, stored = NOTHING_STORED, words = bareChe
190
392
  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
393
  if (!attachment && original.text === "")
192
394
  throw new CliError("validation_error", "that message has nothing to forward");
395
+ // A channel's files carry no key, and a chat's recipient cannot open a file without one.
396
+ if (isChannel(fromChatId) && attachment?.ref && "directPath" in attachment.ref)
397
+ 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; " +
398
+ "save it on the phone and send it as a new file", { reason: "media.channel-file-forward" });
193
399
  const copy = await translated(engine.forward({ chat: toChatId, id: sendId, text: original.text, attachment: attachment ?? null }));
194
400
  return { ...toMessage(copy), forwardedFrom: quoted(original) };
195
401
  },
196
402
  // 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`.
403
+ // contact card) or that the store has no keys for is named in `skipped`. The message's key goes along, so the
404
+ // engine can ask the phone for a file WhatsApp no longer keeps.
198
405
  download: async (chat, messageId) => {
199
406
  const { id: chatId } = await core.resolve(chat);
200
407
  const message = await held(chatId, messageId);
@@ -202,18 +409,19 @@ export const whatsAppAdapter = (engine, stored = NOTHING_STORED, words = bareChe
202
409
  const skipped = [];
203
410
  message.attachments.forEach((stored, position) => {
204
411
  const attachment = toWaAttachment(stored);
205
- if (!attachment?.ref) {
412
+ if (!isFile(attachment)) {
206
413
  skipped.push(stored.kind);
207
414
  return;
208
415
  }
209
- files.push({
416
+ const file = {
210
417
  kind: attachment.kind,
211
418
  ...(attachment.name === undefined ? {} : { name: attachment.name }),
212
419
  ...(attachment.mime === undefined ? {} : { mime: attachment.mime }),
213
420
  ...(attachment.size === undefined ? {} : { size: attachment.size }),
214
421
  position,
215
- bytes: () => fetched(attachment),
216
- });
422
+ bytes: () => fetched(file, attachment, keyOf(chatId, message)),
423
+ };
424
+ files.push(file);
217
425
  });
218
426
  return { files, skipped };
219
427
  },
@@ -229,20 +437,44 @@ export const whatsAppAdapter = (engine, stored = NOTHING_STORED, words = bareChe
229
437
  return { ...original, text: edited.text, editedAt: edited.editedAt };
230
438
  },
231
439
  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
440
  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)));
441
+ const done = [];
442
+ for (const message of messages) {
443
+ try {
444
+ if (forEveryone)
445
+ await translated(engine.revoke(keyOf(chatId, message)));
446
+ else
447
+ await synced(engine.deleteForMe({ ...keyOf(chatId, message), timestamp: message.timestamp }));
448
+ }
449
+ catch (error) {
450
+ if (done.length === 0 || !(error instanceof CliError))
451
+ throw error;
452
+ // One request per message: the ones before this went, so the run as a whole is not unsent (WC-OUT-20).
453
+ const { sent: _, ...details } = error.details;
454
+ throw new CliError(error.code, `deleted ${done.join(", ")}; ${message.id} and any after it were not, or may not have been: ${error.message}`, details);
455
+ }
456
+ done.push(message.id);
457
+ }
237
458
  },
238
459
  react: async (chatId, messageId, emoji) => {
239
460
  const message = await held(chatId, messageId);
240
461
  await translated(engine.react(keyOf(chatId, message), emoji));
241
462
  },
242
- // One receipt, for the newest message from someone else up to `until`: the account's own messages have none.
463
+ star: async (chatId, messageId, starred) => {
464
+ const message = await held(chatId, messageId);
465
+ await synced(engine.star(keyOf(chatId, message), starred));
466
+ },
467
+ // The read patch takes a mark-unread off the owner's devices, which a receipt does not; it is bounded by `until`
468
+ // or the newest stored message, either side's, so it names nothing newer than the receipt covers. It goes first,
469
+ // so a refusal for missing settings keys leaves nothing sent. Then one receipt, for the newest message from
470
+ // someone else up to `until`: the account's own messages have none. A chat the store holds nothing of has neither.
243
471
  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);
472
+ const anchor = until === undefined ? await stored.anchor(chatId, AFTER_EVERY_MESSAGE) : await held(chatId, until);
473
+ if (!anchor)
474
+ return;
475
+ if (!isChannel(chatId) && !chatId.endsWith("@broadcast"))
476
+ await synced(engine.markChatRead({ ...keyOf(chatId, anchor), timestamp: anchor.timestamp }));
477
+ const target = until !== undefined && !anchor.outgoing ? anchor : await stored.newestIncoming(chatId, until);
246
478
  if (target)
247
479
  await translated(engine.markRead(keyOf(chatId, target)));
248
480
  },
@@ -251,6 +483,8 @@ export const whatsAppAdapter = (engine, stored = NOTHING_STORED, words = bareChe
251
483
  // page reached (`before` is a time, `fetching.orderBy: "time"`).
252
484
  history: async (chat, { limit, before }) => {
253
485
  const { id: chatId } = await core.resolve(chat);
486
+ if (isChannel(chatId))
487
+ return translated(channelHistory(engine, stored, chatId, { limit, ...(before === undefined ? {} : { before }) }));
254
488
  const anchor = await stored.anchor(chatId, before);
255
489
  if (!anchor)
256
490
  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 +493,13 @@ export const whatsAppAdapter = (engine, stored = NOTHING_STORED, words = bareChe
259
493
  const items = older.messages.map(toMessage).sort((one, other) => one.timestamp.localeCompare(other.timestamp));
260
494
  return { items, hasMore: older.more };
261
495
  },
262
- logout: () => translated(engine.logout()),
496
+ logout: () => {
497
+ loggingOut = true;
498
+ return translated(engine.logout());
499
+ },
263
500
  close: () => engine.close(),
264
501
  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
- });
502
+ const live = handingOn(onEvent, signal);
289
503
  try {
290
504
  await translated(engine.ready(signal));
291
505
  if (!signal.aborted)
@@ -293,34 +507,48 @@ export const whatsAppAdapter = (engine, stored = NOTHING_STORED, words = bareChe
293
507
  await live.done;
294
508
  }
295
509
  finally {
296
- live.stop();
297
- await queue;
510
+ await live.stop();
298
511
  }
299
512
  },
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
- });
513
+ // What a one-shot connection receives: WhatsApp hands a queued message to a linked device once, so it is saved
514
+ // here or lost. Never connects; the command's own request does that.
515
+ listen: async (onEvent, signal) => {
516
+ const live = handingOn(onEvent, signal, ownLogout);
312
517
  try {
313
- await pushes.done;
518
+ await live.done;
314
519
  }
315
520
  finally {
316
- pushes.stop();
521
+ await live.stop();
317
522
  }
318
523
  },
524
+ // The history a one-shot connection is pushed, which WhatsApp likewise sends once. Never connects.
525
+ listenHistory: (onBatch, signal) => pushing(onBatch, signal, ownLogout),
526
+ feed: (onBatch, signal) => pushing(onBatch, signal),
527
+ // 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.
528
+ storedChats: async (ids, recipients = []) => {
529
+ const phones = ids.filter((id) => PHONE_CHAT.test(id));
530
+ const typed = recipients.filter((id) => PHONE_CHAT.test(id));
531
+ if (phones.length > 0 || typed.length > 0)
532
+ await translated(engine.storedChats(phones, typed.length > 0 ? typed : undefined));
533
+ },
319
534
  };
535
+ const polls = pollAdapter({ engine, stored, held, keyOf, translated });
536
+ const groups = groupAdapter({
537
+ engine,
538
+ resolve: core.resolve,
539
+ translated,
540
+ wroteTo: (person) => stored.wroteTo(person),
541
+ onDisk,
542
+ });
320
543
  return {
321
544
  ...core,
322
- ...groupAdapter({ engine, resolve: core.resolve, translated, wroteTo: (person) => stored.wroteTo(person) }),
323
- ...pollAdapter({ engine, stored, held, keyOf, translated }),
545
+ ...groups,
546
+ ...channelAdapter({ engine, resolve: core.resolve, translated, groups }),
547
+ ...polls,
548
+ ...channelWrites({ ...core, ...polls }),
549
+ ...privacyAdapter({ engine, translated }),
550
+ ...pinAdapter({ engine, held, keyOf, translated }),
551
+ ...settingsAdapter({ engine, translated, words, stored, keyOf, onDisk }),
324
552
  };
325
553
  };
326
554
  //# sourceMappingURL=adapter.js.map