meta-sovereign 0.49.0 → 0.49.2
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/js/src/automation/outbox.js +12 -1
- package/js/src/broadcast/index.js +7 -1
- package/js/src/crm/reengage.js +3 -2
- package/js/src/greetings/index.js +6 -2
- package/js/src/server/index.js +26 -5
- package/js/src/server/routes-whatsapp.js +103 -0
- package/js/src/sources/auto-import/files.js +9 -5
- package/js/src/sources/auto-import/recipes.js +8 -0
- package/js/src/sources/browser-sender.js +43 -5
- package/js/src/sources/web-uis.js +20 -3
- package/js/src/sources/whatsapp-archive.js +271 -0
- package/js/src/sources/whatsapp-file.js +41 -0
- package/js/src/sources/whatsapp-ids.js +94 -0
- package/js/src/sources/whatsapp-web-signin.js +156 -0
- package/js/src/sources/whatsapp.js +27 -30
- package/js/src/storage/migrations/0002-whatsapp-chat-ids.js +148 -0
- package/js/src/storage/migrations/index.js +3 -2
- package/js/src/web/connection-guides.js +2 -2
- package/js/src/web/connections/index.js +30 -0
- package/js/src/web/connections/whatsapp-web.js +123 -0
- package/js/src/web/dom.js +11 -0
- package/js/src/web/locales/en.js +15 -1
- package/js/src/web/locales/hi.js +16 -1
- package/js/src/web/locales/ru.js +15 -1
- package/js/src/web/locales/zh.js +14 -1
- package/package.json +4 -2
|
@@ -208,6 +208,10 @@ export const guardedSend = async ({
|
|
|
208
208
|
draft,
|
|
209
209
|
send,
|
|
210
210
|
settings = null,
|
|
211
|
+
// The user pressed Send on this very message. Anything else is an
|
|
212
|
+
// automated send and carries `automated: true` to the sender, which
|
|
213
|
+
// refuses WhatsApp Web for it (R-Z9).
|
|
214
|
+
userConfirmed = false,
|
|
211
215
|
clock = () => new Date(),
|
|
212
216
|
log = () => {},
|
|
213
217
|
}) => {
|
|
@@ -247,7 +251,14 @@ export const guardedSend = async ({
|
|
|
247
251
|
} catch (error) {
|
|
248
252
|
return refused('outbox-unavailable', `not sent: ${error.message}`);
|
|
249
253
|
}
|
|
250
|
-
return await deliverOnce({
|
|
254
|
+
return await deliverOnce({
|
|
255
|
+
store,
|
|
256
|
+
record,
|
|
257
|
+
draft: { ...draft, automated: !userConfirmed },
|
|
258
|
+
send,
|
|
259
|
+
clock,
|
|
260
|
+
log,
|
|
261
|
+
});
|
|
251
262
|
} finally {
|
|
252
263
|
running.delete(id);
|
|
253
264
|
}
|
|
@@ -93,7 +93,13 @@ const postOn = async (name, content, outbox, resolveSource) => {
|
|
|
93
93
|
chat: content?.chat ?? null,
|
|
94
94
|
text: typeof content === 'string' ? content : (content?.text ?? ''),
|
|
95
95
|
},
|
|
96
|
-
send: (
|
|
96
|
+
send: ({ automated }) =>
|
|
97
|
+
asSend(name, 'post', () =>
|
|
98
|
+
// WhatsApp refuses an automated personal send (R-Z9).
|
|
99
|
+
name === 'whatsapp'
|
|
100
|
+
? live.post(content, { automated })
|
|
101
|
+
: live.post(content)
|
|
102
|
+
),
|
|
97
103
|
});
|
|
98
104
|
return outcome.status === 'sent'
|
|
99
105
|
? { network: name, ok: true, result: outcome.result }
|
package/js/src/crm/reengage.js
CHANGED
|
@@ -525,7 +525,7 @@ export const createReengageSession = ({
|
|
|
525
525
|
index += 1;
|
|
526
526
|
return current();
|
|
527
527
|
};
|
|
528
|
-
const sendCurrent = async () => {
|
|
528
|
+
const sendCurrent = async (userConfirmed = false) => {
|
|
529
529
|
const draft = requireCurrent();
|
|
530
530
|
if (typeof send !== 'function') {
|
|
531
531
|
throw new Error('re-engage session has no sender; use markSent()');
|
|
@@ -536,6 +536,7 @@ export const createReengageSession = ({
|
|
|
536
536
|
owner: 'reengage',
|
|
537
537
|
draft,
|
|
538
538
|
send,
|
|
539
|
+
userConfirmed,
|
|
539
540
|
clock,
|
|
540
541
|
});
|
|
541
542
|
if (outcome.status === 'sent') {
|
|
@@ -579,7 +580,7 @@ export const createReengageSession = ({
|
|
|
579
580
|
return items[index];
|
|
580
581
|
},
|
|
581
582
|
/** Send the current draft — the one action safe mode allows. */
|
|
582
|
-
confirm: sendCurrent,
|
|
583
|
+
confirm: () => sendCurrent(true),
|
|
583
584
|
/** The user sent it in their own app; record it and move on. */
|
|
584
585
|
async markSent() {
|
|
585
586
|
const draft = requireCurrent();
|
|
@@ -151,8 +151,12 @@ const sendEnvelope = async (
|
|
|
151
151
|
contact: envelope.contact,
|
|
152
152
|
text: envelope.text ?? `sticker:${envelope.sticker?.id ?? ''}`,
|
|
153
153
|
},
|
|
154
|
-
send: () =>
|
|
155
|
-
live.post(outgoingContent(envelope),
|
|
154
|
+
send: ({ automated }) =>
|
|
155
|
+
live.post(outgoingContent(envelope), {
|
|
156
|
+
...(credentials[envelope.network] ?? {}),
|
|
157
|
+
// WhatsApp refuses an automated personal send (R-Z9).
|
|
158
|
+
...(envelope.network === 'whatsapp' ? { automated } : {}),
|
|
159
|
+
}),
|
|
156
160
|
clock,
|
|
157
161
|
});
|
|
158
162
|
if (outcome.status !== 'sent') {
|
package/js/src/server/index.js
CHANGED
|
@@ -87,6 +87,8 @@ import { createChatIndex } from './chat-index.js';
|
|
|
87
87
|
import { handleChatRoutes } from './routes-chat.js';
|
|
88
88
|
import { createClinkReplyStore } from '../replies/reply-store-clink.js';
|
|
89
89
|
import { handleAutoImportRoutes } from './routes-auto-import.js';
|
|
90
|
+
import { handleWhatsAppRoutes } from './routes-whatsapp.js';
|
|
91
|
+
import { profileDirIn } from '../sources/whatsapp-web-signin.js';
|
|
90
92
|
import { handleTelegramRoutes } from './routes-telegram.js';
|
|
91
93
|
import { handleMessageRoutes } from './routes-messages.js';
|
|
92
94
|
import { handleEmailGets } from './routes-email.js';
|
|
@@ -573,6 +575,7 @@ const routeOn = async (store, req, res, ctx, url, p) => {
|
|
|
573
575
|
() => handleEventRoutes(store, req, res, p, ctx),
|
|
574
576
|
() => ctx.clientLog?.(req, res, p, ctx) ?? false,
|
|
575
577
|
() => handleAutoImportRoutes(store, req, res, p, ctx),
|
|
578
|
+
() => handleWhatsAppRoutes(store, req, res, p, ctx),
|
|
576
579
|
() => handleTelegramRoutes(req, res, p, ctx),
|
|
577
580
|
() => handleMessageRoutes(store, req, res, p, url, ctx),
|
|
578
581
|
() => handleEmailGets(store, req, res, p, url, ctx),
|
|
@@ -721,6 +724,26 @@ const attachSockets = (server, ctx, store, { enableSync, node }) => {
|
|
|
721
724
|
return { wsHandle, signaling };
|
|
722
725
|
};
|
|
723
726
|
|
|
727
|
+
// The browser sender signs in to the web apps in profiles inside the store
|
|
728
|
+
// (owner only), the same ones "Sign in to WhatsApp Web" opens (R-Z9).
|
|
729
|
+
const withStoreProfiles = (automations = {}, storeDir) => ({
|
|
730
|
+
...automations,
|
|
731
|
+
browser: {
|
|
732
|
+
profileDirFor: (source) => profileDirIn(storeDir, source),
|
|
733
|
+
...automations.browser,
|
|
734
|
+
},
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
// The open connections of `server`, so a shutdown can end them.
|
|
738
|
+
const trackSockets = (server) => {
|
|
739
|
+
const sockets = new Set();
|
|
740
|
+
server.on('connection', (socket) => {
|
|
741
|
+
sockets.add(socket);
|
|
742
|
+
socket.on('close', () => sockets.delete(socket));
|
|
743
|
+
});
|
|
744
|
+
return sockets;
|
|
745
|
+
};
|
|
746
|
+
|
|
724
747
|
// The automations' part of the context, fed by the Telegram account.
|
|
725
748
|
const automationFor = (store, automations, log, telegramAccount, later) =>
|
|
726
749
|
automationContext(
|
|
@@ -783,6 +806,7 @@ export const startServer = async ({
|
|
|
783
806
|
});
|
|
784
807
|
// Services that run in the background start only after the port is ours.
|
|
785
808
|
const later = [];
|
|
809
|
+
automations = withStoreProfiles(automations, storeDir);
|
|
786
810
|
const chatIndex = createChatIndex(store);
|
|
787
811
|
const telegramAccount = telegramAccountFor(
|
|
788
812
|
store,
|
|
@@ -831,11 +855,7 @@ export const startServer = async ({
|
|
|
831
855
|
chatIndex,
|
|
832
856
|
};
|
|
833
857
|
const server = createHttpServer(store, ctx, log);
|
|
834
|
-
const sockets =
|
|
835
|
-
server.on('connection', (socket) => {
|
|
836
|
-
sockets.add(socket);
|
|
837
|
-
socket.on('close', () => sockets.delete(socket));
|
|
838
|
-
});
|
|
858
|
+
const sockets = trackSockets(server);
|
|
839
859
|
let bus = null;
|
|
840
860
|
if (enableHandlers) {
|
|
841
861
|
bus = registerDefaultHandlers(store, { browser: () => ctx.send });
|
|
@@ -882,6 +902,7 @@ export const startServer = async ({
|
|
|
882
902
|
ctx.replies.close().catch(() => {});
|
|
883
903
|
ctx.closeAutomations().catch(() => {});
|
|
884
904
|
ctx.autoImport?.close();
|
|
905
|
+
ctx.whatsappSignIn?.stop().catch(() => {});
|
|
885
906
|
ctx.chatIndex.close();
|
|
886
907
|
stopAll(telegramAccount, ctx.telegramAccounts, ctx.telegramExtras);
|
|
887
908
|
wsHandle?.close();
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WhatsApp routes (R-Z9).
|
|
3
|
+
*
|
|
4
|
+
* GET /api/whatsapp/web -> { ok, state, open, signedIn, signedInAt, terms }
|
|
5
|
+
* POST /api/whatsapp/web/sign-in -> opens the visible WhatsApp Web window
|
|
6
|
+
* POST /api/whatsapp/web/close -> closes it
|
|
7
|
+
* POST /api/whatsapp/archive {name, data(base64)}
|
|
8
|
+
* -> imports an "Export chat" .txt or .zip
|
|
9
|
+
*
|
|
10
|
+
* The sign-in window is for the person to scan the QR code themselves
|
|
11
|
+
* (see ../sources/whatsapp-web-signin.js); these routes never touch it.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { json, readBody } from './util.js';
|
|
15
|
+
import { isTrustedMutation } from './routes-auto-import.js';
|
|
16
|
+
import { safeMessage } from './redact.js';
|
|
17
|
+
import { createWhatsAppWebSignIn } from '../sources/whatsapp-web-signin.js';
|
|
18
|
+
import { importWhatsAppFile } from '../sources/whatsapp-file.js';
|
|
19
|
+
|
|
20
|
+
const PREFIX = '/api/whatsapp/';
|
|
21
|
+
|
|
22
|
+
// What the page must tell the person (also in the docs, four languages).
|
|
23
|
+
export const WHATSAPP_TERMS = Object.freeze({
|
|
24
|
+
automation: false,
|
|
25
|
+
note: "WhatsApp's terms do not allow automated or bulk use of WhatsApp Web. Meta Sovereign only types and sends a message you confirm yourself.",
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const signInFor = (ctx) => {
|
|
29
|
+
ctx.whatsappSignIn ??= createWhatsAppWebSignIn({
|
|
30
|
+
storeDir: ctx.storeDir,
|
|
31
|
+
...(ctx.whatsappSignInOptions ?? {}),
|
|
32
|
+
log: ctx.log,
|
|
33
|
+
});
|
|
34
|
+
return ctx.whatsappSignIn;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const reply = (res, status, body) =>
|
|
38
|
+
json(res, status, { ok: status < 400, ...body }) ?? true;
|
|
39
|
+
|
|
40
|
+
const failure = (res, error) =>
|
|
41
|
+
reply(res, error.code === 'browser-unavailable' ? 503 : 500, {
|
|
42
|
+
error: safeMessage(error),
|
|
43
|
+
code: error.code ?? 'internal',
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const archiveRoute = async (store, req, res) => {
|
|
47
|
+
const body = await readBody(req, { limit: 64 * 1024 * 1024 });
|
|
48
|
+
const bytes = Buffer.from(String(body.data ?? ''), 'base64');
|
|
49
|
+
if (bytes.length === 0 || typeof body.name !== 'string') {
|
|
50
|
+
return reply(res, 400, {
|
|
51
|
+
error: 'name and data (base64) are required',
|
|
52
|
+
code: 'bad-request',
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
return reply(res, 200, await importWhatsAppFile(store, body.name, bytes));
|
|
57
|
+
} catch (error) {
|
|
58
|
+
return reply(res, 400, { error: safeMessage(error), code: 'bad-archive' });
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const webRoute = async (req, res, ctx, action) => {
|
|
63
|
+
const signIn = signInFor(ctx);
|
|
64
|
+
if (req.method === 'GET') {
|
|
65
|
+
return reply(res, 200, {
|
|
66
|
+
...(await signIn.status()),
|
|
67
|
+
terms: WHATSAPP_TERMS,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
const status =
|
|
72
|
+
action === 'sign-in' ? await signIn.start() : await signIn.stop();
|
|
73
|
+
return reply(res, 200, { ...status, terms: WHATSAPP_TERMS });
|
|
74
|
+
} catch (error) {
|
|
75
|
+
return failure(res, error);
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export const handleWhatsAppRoutes = async (store, req, res, p, ctx) => {
|
|
80
|
+
if (!p.startsWith(PREFIX)) {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
const route = p.slice(PREFIX.length);
|
|
84
|
+
const isWeb =
|
|
85
|
+
route === 'web' || route === 'web/sign-in' || route === 'web/close';
|
|
86
|
+
const isArchive = route === 'archive';
|
|
87
|
+
if (!isWeb && !isArchive) {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
if (req.method === 'POST' && !isTrustedMutation(req, ctx.allowOrigins)) {
|
|
91
|
+
return reply(res, 403, {
|
|
92
|
+
error: 'this must be started from the meta-sovereign app',
|
|
93
|
+
code: 'forbidden',
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
if (isArchive) {
|
|
97
|
+
return req.method === 'POST' ? archiveRoute(store, req, res) : false;
|
|
98
|
+
}
|
|
99
|
+
const expected = route === 'web' ? 'GET' : 'POST';
|
|
100
|
+
return req.method === expected
|
|
101
|
+
? webRoute(req, res, ctx, route.split('/')[1])
|
|
102
|
+
: false;
|
|
103
|
+
};
|
|
@@ -9,9 +9,9 @@ import { getSource, stampSourceLink, buildMessageLink } from '../index.js';
|
|
|
9
9
|
import { archiveReaderFor } from './archive.js';
|
|
10
10
|
import { autoImportRecipes } from './recipes.js';
|
|
11
11
|
|
|
12
|
-
const toArchive = (recipe, name, text) => {
|
|
12
|
+
const toArchive = (recipe, name, text, context = {}) => {
|
|
13
13
|
if (recipe.toArchive) {
|
|
14
|
-
return recipe.toArchive(name, text);
|
|
14
|
+
return recipe.toArchive(name, text, context);
|
|
15
15
|
}
|
|
16
16
|
const trimmed = text.trim();
|
|
17
17
|
if (/\.json$/i.test(name) || /^[[{]/.test(trimmed)) {
|
|
@@ -24,8 +24,8 @@ const toArchive = (recipe, name, text) => {
|
|
|
24
24
|
return text;
|
|
25
25
|
};
|
|
26
26
|
|
|
27
|
-
const parseEntry = async (provider, recipe, name, data) => {
|
|
28
|
-
const archive = toArchive(recipe, name, data.toString('utf8'));
|
|
27
|
+
const parseEntry = async (provider, recipe, name, data, context) => {
|
|
28
|
+
const archive = toArchive(recipe, name, data.toString('utf8'), context);
|
|
29
29
|
return (await getSource(provider).parseArchive(archive)) ?? [];
|
|
30
30
|
};
|
|
31
31
|
|
|
@@ -47,6 +47,7 @@ export const importDownloadedFile = async ({ store, provider, filePath }) => {
|
|
|
47
47
|
: recipe.files.test(name)
|
|
48
48
|
? [{ name, read: () => buf }]
|
|
49
49
|
: [];
|
|
50
|
+
const names = reader ? reader(buf).map((entry) => entry.name) : [];
|
|
50
51
|
const reports = [];
|
|
51
52
|
for (const entry of candidates) {
|
|
52
53
|
const label = reader ? `${name}:${entry.name}` : name;
|
|
@@ -55,7 +56,10 @@ export const importDownloadedFile = async ({ store, provider, filePath }) => {
|
|
|
55
56
|
provider,
|
|
56
57
|
recipe,
|
|
57
58
|
entry.name,
|
|
58
|
-
entry.read()
|
|
59
|
+
entry.read(),
|
|
60
|
+
// The container's own name and file list (WhatsApp names the chat
|
|
61
|
+
// after the zip and finds the media in it).
|
|
62
|
+
{ container: reader ? name : null, entries: names }
|
|
59
63
|
);
|
|
60
64
|
for (const link of links) {
|
|
61
65
|
await store.put(stampSourceLink(link, provider));
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
import { parseCsv } from './archive.js';
|
|
25
|
+
import { chatNameFromFile } from '../whatsapp-ids.js';
|
|
25
26
|
|
|
26
27
|
// X archives ship data as `window.YTD.<name>.part0 = [ ... ]`.
|
|
27
28
|
const ytdPayload = (text) => JSON.parse(text.replace(/^[^=[]*=\s*/, ''));
|
|
@@ -97,7 +98,14 @@ export const autoImportRecipes = {
|
|
|
97
98
|
whatsapp: {
|
|
98
99
|
url: 'https://web.whatsapp.com/',
|
|
99
100
|
domains: ['whatsapp.com'],
|
|
101
|
+
// An "Export chat" .txt, or the .zip of one with media (R-Z9); the
|
|
102
|
+
// chat is named after the file (or the zip holding `_chat.txt`).
|
|
100
103
|
files: /\.txt$/i,
|
|
104
|
+
toArchive: (name, text, context = {}) => ({
|
|
105
|
+
text,
|
|
106
|
+
fileName: chatNameFromFile(name) ? name : (context.container ?? name),
|
|
107
|
+
files: context.entries ?? [],
|
|
108
|
+
}),
|
|
101
109
|
scrape: {
|
|
102
110
|
chat: '#main header span[dir="auto"]',
|
|
103
111
|
message: '#main div[data-id]',
|
|
@@ -117,9 +117,16 @@ export const extractMessages = (spec) => {
|
|
|
117
117
|
const match = raw && pattern ? pattern.exec(raw) : null;
|
|
118
118
|
return match ? match[1] : raw;
|
|
119
119
|
};
|
|
120
|
+
const chatPattern = spec.chatPattern ? new RegExp(spec.chatPattern) : null;
|
|
121
|
+
const chatOf = (node) => {
|
|
122
|
+
const raw = spec.idAttribute ? node.getAttribute(spec.idAttribute) : null;
|
|
123
|
+
const match = raw && chatPattern ? chatPattern.exec(raw) : null;
|
|
124
|
+
return match ? match[1] : null;
|
|
125
|
+
};
|
|
120
126
|
const rows = Array.from(document.querySelectorAll(spec.item));
|
|
121
127
|
return rows.slice(-spec.limit).map((node) => ({
|
|
122
128
|
id: idOf(node),
|
|
129
|
+
...(chatPattern ? { chat: chatOf(node) } : {}),
|
|
123
130
|
sender: squash(pick(node, spec.sender, spec.senderAttribute)),
|
|
124
131
|
body: String(pick(node, spec.body) ?? '').trim(),
|
|
125
132
|
time: squash(pick(node, spec.time, spec.timeAttribute)) || null,
|
|
@@ -169,7 +176,7 @@ const toMessageLink = (source, chat, row) => {
|
|
|
169
176
|
};
|
|
170
177
|
|
|
171
178
|
/** First candidate of a comma-separated selector list that matches. */
|
|
172
|
-
const firstMatch = async (commander, selector) => {
|
|
179
|
+
export const firstMatch = async (commander, selector) => {
|
|
173
180
|
for (const candidate of selectorCandidates(selector)) {
|
|
174
181
|
if ((await commander.count({ selector: candidate })) > 0) {
|
|
175
182
|
return candidate;
|
|
@@ -488,6 +495,11 @@ export const createBrowserSender = ({
|
|
|
488
495
|
async readRecent({ source, chat, to, limit = 20 } = {}) {
|
|
489
496
|
const ui = uiFor(source);
|
|
490
497
|
const target = ui.target({ chat, to });
|
|
498
|
+
// A stable chat id (E.164 number / group JID), not the typed form
|
|
499
|
+
// and not the page's title (R-Z9).
|
|
500
|
+
const normalize = (value) => ui.normalizeChat?.(value) ?? null;
|
|
501
|
+
const chatOf = (row) =>
|
|
502
|
+
normalize(row.chat) ?? normalize(target) ?? String(target);
|
|
491
503
|
const rows = await inChat(source, target, {}, (commander) =>
|
|
492
504
|
commander.evaluate({
|
|
493
505
|
fn: extractMessages,
|
|
@@ -496,7 +508,7 @@ export const createBrowserSender = ({
|
|
|
496
508
|
);
|
|
497
509
|
return (Array.isArray(rows) ? rows : [])
|
|
498
510
|
.filter((row) => row && String(row.body ?? '').length > 0)
|
|
499
|
-
.map((row) => toMessageLink(source,
|
|
511
|
+
.map((row) => toMessageLink(source, chatOf(row), row));
|
|
500
512
|
},
|
|
501
513
|
|
|
502
514
|
/** Networks with an open page. */
|
|
@@ -572,6 +584,10 @@ export const createFallbackSender =
|
|
|
572
584
|
apiError ? { apiError: apiError.message } : {}
|
|
573
585
|
);
|
|
574
586
|
} catch (error) {
|
|
587
|
+
// "Not allowed to automate this" is the answer, not a pair of failures.
|
|
588
|
+
if (error?.code === 'unsupported') {
|
|
589
|
+
throw error;
|
|
590
|
+
}
|
|
575
591
|
if (!apiError) {
|
|
576
592
|
throw error;
|
|
577
593
|
}
|
|
@@ -582,6 +598,26 @@ export const createFallbackSender =
|
|
|
582
598
|
}
|
|
583
599
|
};
|
|
584
600
|
|
|
601
|
+
/**
|
|
602
|
+
* Networks whose web app may only be driven for a send the user confirms:
|
|
603
|
+
* WhatsApp's terms forbid automated and bulk use of Web, so an automated
|
|
604
|
+
* draft (`automated: true`, set by `guardedSend` for everything the user
|
|
605
|
+
* did not press Send on) is refused with `unsupported` (R-Z9).
|
|
606
|
+
*/
|
|
607
|
+
export const WEB_USER_CONFIRMED_ONLY = Object.freeze(['whatsapp']);
|
|
608
|
+
|
|
609
|
+
const refuseAutomatedWeb = (draft) => {
|
|
610
|
+
if (
|
|
611
|
+
draft?.automated === true &&
|
|
612
|
+
WEB_USER_CONFIRMED_ONLY.includes(draft.source)
|
|
613
|
+
) {
|
|
614
|
+
throw codedError(
|
|
615
|
+
`${draft.source}: an automated send cannot go through the web app (its terms forbid automation); send it yourself, or connect the Business Cloud API`,
|
|
616
|
+
'unsupported'
|
|
617
|
+
);
|
|
618
|
+
}
|
|
619
|
+
};
|
|
620
|
+
|
|
585
621
|
/**
|
|
586
622
|
* The sender automations and the chat composer use: the source adapter's
|
|
587
623
|
* `live.post` first, the official web UI second. It also does every other
|
|
@@ -624,13 +660,15 @@ export const createDefaultSender = ({
|
|
|
624
660
|
: null;
|
|
625
661
|
const send = createFallbackSender({
|
|
626
662
|
apiSend: adapterSend,
|
|
627
|
-
browserSend: (draft) =>
|
|
628
|
-
|
|
663
|
+
browserSend: (draft) => {
|
|
664
|
+
refuseAutomatedWeb(draft);
|
|
665
|
+
return browserFor().send({
|
|
629
666
|
source: draft.source,
|
|
630
667
|
chat: draft.chat,
|
|
631
668
|
to: draft.contact ?? draft.to,
|
|
632
669
|
text: draft.text,
|
|
633
|
-
})
|
|
670
|
+
});
|
|
671
|
+
},
|
|
634
672
|
log,
|
|
635
673
|
});
|
|
636
674
|
const actions = createMessageActions({
|
|
@@ -33,6 +33,8 @@
|
|
|
33
33
|
* the message, shown on hover), then the menu items.
|
|
34
34
|
*/
|
|
35
35
|
|
|
36
|
+
import { chatIdOf, parseChatRef } from './whatsapp-ids.js';
|
|
37
|
+
|
|
36
38
|
/** VK peer ids of group chats start here (`messages.send` peer_id). */
|
|
37
39
|
const VK_CHAT_PEER_OFFSET = 2000000000;
|
|
38
40
|
|
|
@@ -170,12 +172,25 @@ const whatsapp = {
|
|
|
170
172
|
name: 'WhatsApp',
|
|
171
173
|
loginUrl: 'https://web.whatsapp.com/',
|
|
172
174
|
// The click-to-chat URL opens the chat and pre-fills the composer.
|
|
175
|
+
// Only a person's number opens a chat this way. A group, a linked id or
|
|
176
|
+
// a `title:` chat has no number: stripping its digits would open the
|
|
177
|
+
// WRONG person's chat, so it is refused (R-Z9).
|
|
173
178
|
chatUrl: (target, { text = '' } = {}) => {
|
|
174
|
-
const
|
|
179
|
+
const ref = parseChatRef(target);
|
|
180
|
+
if (ref?.kind !== 'person') {
|
|
181
|
+
throw Object.assign(
|
|
182
|
+
new Error(
|
|
183
|
+
`whatsapp: "${target}" is not a phone number; WhatsApp Web can only be opened by number from here`
|
|
184
|
+
),
|
|
185
|
+
{ code: 'unsupported-target' }
|
|
186
|
+
);
|
|
187
|
+
}
|
|
175
188
|
const query = text ? `&text=${encodeURIComponent(text)}` : '';
|
|
176
|
-
return `https://web.whatsapp.com/send?phone=${
|
|
189
|
+
return `https://web.whatsapp.com/send?phone=${ref.id.slice(1)}${query}`;
|
|
177
190
|
},
|
|
178
191
|
target: ({ chat, to }) => to ?? chat,
|
|
192
|
+
// The stable chat id of a JID or number (E.164 / group JID).
|
|
193
|
+
normalizeChat: chatIdOf,
|
|
179
194
|
loggedIn: '#pane-side, div[aria-label="Chat list"], #main footer',
|
|
180
195
|
loggedOut: 'canvas[aria-label="Scan me!"], div[data-ref], .landing-main',
|
|
181
196
|
composer:
|
|
@@ -191,7 +206,9 @@ const whatsapp = {
|
|
|
191
206
|
idAttribute: 'data-id',
|
|
192
207
|
// `data-id` is `<out>_<chat jid>_<message id>`: the message id is
|
|
193
208
|
// what the Cloud API calls `wamid`'s last part.
|
|
194
|
-
|
|
209
|
+
// Groups add the participant: `false_<group>@g.us_<msg>_<who>@c.us`.
|
|
210
|
+
idPattern: '^(?:true|false)_[^_]+_([^_]+)',
|
|
211
|
+
chatPattern: '^(?:true|false)_([^_]+)_',
|
|
195
212
|
sender: '[data-pre-plain-text]',
|
|
196
213
|
senderAttribute: 'data-pre-plain-text',
|
|
197
214
|
body: 'span.selectable-text, .copyable-text span',
|