@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.
- 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 +313 -85
- 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 +16 -2
- package/dist/adapter/map.d.ts.map +1 -1
- package/dist/adapter/map.js +56 -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 +100 -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 +475 -43
- package/dist/engine/contract.d.ts.map +1 -1
- package/dist/engine/contract.js +178 -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 +26 -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 +29 -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, } 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, 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,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
|
-
|
|
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
|
-
|
|
131
|
-
|
|
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 =
|
|
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
|
|
163
|
-
|
|
164
|
-
|
|
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(
|
|
179
|
-
?
|
|
180
|
-
|
|
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
|
|
412
|
+
if (!isFile(attachment)) {
|
|
206
413
|
skipped.push(stored.kind);
|
|
207
414
|
return;
|
|
208
415
|
}
|
|
209
|
-
|
|
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
|
-
|
|
236
|
-
|
|
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
|
-
|
|
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 ?
|
|
245
|
-
|
|
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: () =>
|
|
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
|
|
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
|
-
|
|
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
|
-
});
|
|
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
|
|
518
|
+
await live.done;
|
|
314
519
|
}
|
|
315
520
|
finally {
|
|
316
|
-
|
|
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
|
-
...
|
|
323
|
-
...
|
|
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
|