@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.
- package/README.md +213 -94
- package/dist/adapter/adapter.d.ts +8 -4
- package/dist/adapter/adapter.d.ts.map +1 -1
- package/dist/adapter/adapter.js +349 -90
- package/dist/adapter/adapter.js.map +1 -1
- package/dist/adapter/channels.d.ts +43 -0
- package/dist/adapter/channels.d.ts.map +1 -0
- package/dist/adapter/channels.js +100 -0
- package/dist/adapter/channels.js.map +1 -0
- package/dist/adapter/checks.d.ts +2 -3
- package/dist/adapter/checks.d.ts.map +1 -1
- package/dist/adapter/checks.js +1 -2
- package/dist/adapter/checks.js.map +1 -1
- package/dist/adapter/errors.d.ts +0 -5
- package/dist/adapter/errors.d.ts.map +1 -1
- package/dist/adapter/errors.js +7 -9
- package/dist/adapter/errors.js.map +1 -1
- package/dist/adapter/groups.d.ts +5 -1
- package/dist/adapter/groups.d.ts.map +1 -1
- package/dist/adapter/groups.js +51 -9
- package/dist/adapter/groups.js.map +1 -1
- package/dist/adapter/map.d.ts +17 -2
- package/dist/adapter/map.d.ts.map +1 -1
- package/dist/adapter/map.js +68 -7
- package/dist/adapter/map.js.map +1 -1
- package/dist/adapter/pins.d.ts +11 -0
- package/dist/adapter/pins.d.ts.map +1 -0
- package/dist/adapter/pins.js +10 -0
- package/dist/adapter/pins.js.map +1 -0
- package/dist/adapter/polls.d.ts +1 -1
- package/dist/adapter/polls.d.ts.map +1 -1
- package/dist/adapter/polls.js +2 -2
- package/dist/adapter/polls.js.map +1 -1
- package/dist/adapter/privacy.d.ts +18 -0
- package/dist/adapter/privacy.d.ts.map +1 -0
- package/dist/adapter/privacy.js +94 -0
- package/dist/adapter/privacy.js.map +1 -0
- package/dist/adapter/settings.d.ts +21 -0
- package/dist/adapter/settings.d.ts.map +1 -0
- package/dist/adapter/settings.js +76 -0
- package/dist/adapter/settings.js.map +1 -0
- package/dist/bin/wa.js +0 -13
- package/dist/bin/wa.js.map +1 -1
- package/dist/commands/session.d.ts +18 -7
- package/dist/commands/session.d.ts.map +1 -1
- package/dist/commands/session.js +92 -93
- package/dist/commands/session.js.map +1 -1
- package/dist/commands/setup.d.ts.map +1 -1
- package/dist/commands/setup.js +77 -129
- package/dist/commands/setup.js.map +1 -1
- package/dist/commands/update.d.ts.map +1 -1
- package/dist/commands/update.js +3 -22
- package/dist/commands/update.js.map +1 -1
- package/dist/engine/channels.d.ts +109 -0
- package/dist/engine/channels.d.ts.map +1 -0
- package/dist/engine/channels.js +29 -0
- package/dist/engine/channels.js.map +1 -0
- package/dist/engine/contract.d.ts +617 -43
- package/dist/engine/contract.d.ts.map +1 -1
- package/dist/engine/contract.js +226 -11
- package/dist/engine/contract.js.map +1 -1
- package/dist/engine/go-engine.d.ts.map +1 -1
- package/dist/engine/go-engine.js +23 -34
- package/dist/engine/go-engine.js.map +1 -1
- package/dist/engine/groups.d.ts +32 -14
- package/dist/engine/groups.d.ts.map +1 -1
- package/dist/engine/groups.js +2 -0
- package/dist/engine/groups.js.map +1 -1
- package/dist/engine/privacy.d.ts +33 -0
- package/dist/engine/privacy.d.ts.map +1 -0
- package/dist/engine/privacy.js +23 -0
- package/dist/engine/privacy.js.map +1 -0
- package/dist/engine/settings.d.ts +45 -0
- package/dist/engine/settings.d.ts.map +1 -0
- package/dist/engine/settings.js +34 -0
- package/dist/engine/settings.js.map +1 -0
- package/dist/install/postinstall.d.ts.map +1 -1
- package/dist/install/postinstall.js +4 -3
- package/dist/install/postinstall.js.map +1 -1
- package/dist/messenger.d.ts +3 -4
- package/dist/messenger.d.ts.map +1 -1
- package/dist/messenger.js +31 -14
- package/dist/messenger.js.map +1 -1
- package/dist/program.d.ts +5 -1
- package/dist/program.d.ts.map +1 -1
- package/dist/program.js +10 -38
- package/dist/program.js.map +1 -1
- package/dist/reasons.d.ts.map +1 -1
- package/dist/reasons.js +8 -0
- package/dist/reasons.js.map +1 -1
- package/dist/update.d.ts +0 -1
- package/dist/update.d.ts.map +1 -1
- package/dist/update.js +0 -7
- package/dist/update.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +10 -10
- package/skills/whatsapp-cli/SKILL.md +59 -24
- package/dist/commands/answer.d.ts +0 -7
- package/dist/commands/answer.d.ts.map +0 -1
- package/dist/commands/answer.js +0 -11
- package/dist/commands/answer.js.map +0 -1
package/dist/adapter/adapter.js
CHANGED
|
@@ -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 {
|
|
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 = (
|
|
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
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
131
|
-
|
|
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
|
|
148
|
-
|
|
149
|
-
return
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
|
163
|
-
|
|
164
|
-
|
|
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(
|
|
179
|
-
?
|
|
180
|
-
|
|
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
|
|
443
|
+
if (!isFile(attachment)) {
|
|
206
444
|
skipped.push(stored.kind);
|
|
207
445
|
return;
|
|
208
446
|
}
|
|
209
|
-
|
|
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
|
-
|
|
236
|
-
|
|
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
|
-
|
|
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 ?
|
|
245
|
-
|
|
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: () =>
|
|
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
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
|
549
|
+
await live.done;
|
|
314
550
|
}
|
|
315
551
|
finally {
|
|
316
|
-
|
|
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
|
-
...
|
|
323
|
-
...
|
|
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
|