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.
@@ -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({ store, record, draft, send, clock, log });
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: () => asSend(name, 'post', () => live.post(content)),
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 }
@@ -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), credentials[envelope.network] ?? {}),
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') {
@@ -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 = new Set();
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, String(target), row));
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
- browserFor().send({
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 phone = String(target).replace(/\D/g, '');
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=${phone}${query}`;
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
- idPattern: '_([^_]+)$',
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',