@stage-labs/metro 0.1.0-beta.262 → 0.1.0-beta.264

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/dist/runtime-install.js +22 -5
  2. package/package.json +1 -1
  3. package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
  4. package/runtime/marketplace/plugin/bin/policy.mjs +1 -1
  5. package/runtime/node_modules/@metro-labs/core/src/events.ts +45 -1
  6. package/runtime/node_modules/@metro-labs/core/src/stations/train-boot.ts +22 -1
  7. package/runtime/node_modules/@metro-labs/daemon/src/agents/map.ts +2 -0
  8. package/runtime/node_modules/@metro-labs/daemon/src/mcp/policy-gate.ts +3 -3
  9. package/runtime/node_modules/@metro-labs/daemon/src/monitor/run-event.ts +139 -0
  10. package/runtime/node_modules/@metro-labs/daemon/src/monitor/run-events.ts +101 -0
  11. package/runtime/node_modules/@metro-labs/daemon/src/routes/session-apis.ts +2 -0
  12. package/runtime/node_modules/@metro-labs/daemon/src/stations/runtime-deps.ts +23 -7
  13. package/runtime/node_modules/@metro-labs/whatsapp/src/actions.ts +32 -1
  14. package/runtime/node_modules/@metro-labs/whatsapp/src/app-state.ts +49 -0
  15. package/runtime/node_modules/@metro-labs/whatsapp/src/client-history.ts +31 -0
  16. package/runtime/node_modules/@metro-labs/whatsapp/src/client-types.ts +46 -0
  17. package/runtime/node_modules/@metro-labs/whatsapp/src/client.ts +75 -77
  18. package/runtime/node_modules/@metro-labs/whatsapp/src/history-events.ts +127 -0
  19. package/runtime/node_modules/@metro-labs/whatsapp/src/history-files.ts +39 -0
  20. package/runtime/node_modules/@metro-labs/whatsapp/src/history-identity.ts +58 -0
  21. package/runtime/node_modules/@metro-labs/whatsapp/src/history-message.ts +161 -0
  22. package/runtime/node_modules/@metro-labs/whatsapp/src/history-state.ts +344 -0
  23. package/runtime/node_modules/@metro-labs/whatsapp/src/history-types.ts +91 -0
  24. package/runtime/node_modules/@metro-labs/whatsapp/src/history.ts +195 -0
  25. package/runtime/node_modules/@metro-labs/whatsapp/src/index.ts +1 -1
  26. package/runtime/node_modules/@metro-labs/whatsapp/src/members.ts +94 -0
  27. package/runtime/node_modules/@metro-labs/whatsapp/src/messages.ts +70 -0
  28. package/runtime/node_modules/@metro-labs/whatsapp/src/send-expiry.ts +65 -0
  29. package/runtime/node_modules/@metro-labs/whatsapp/src/station.ts +4 -0
  30. package/runtime/patches/5bca2c07d5852d3f58453cff0448dd85da13d3665b61e3d758905b298d432d7c.patch +31 -0
  31. package/runtime/runtime.json +1 -1
  32. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/events.ts +45 -1
  33. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/train-boot.ts +22 -1
  34. package/runtime/sdk-runner/runner.json +1 -1
  35. package/runtime/stations.json +3 -0
@@ -0,0 +1,31 @@
1
+ import { log } from '@metro-labs/core/log';
2
+ import { TrainError } from '@metro-labs/core/train-error';
3
+ import { supportsHistoryDeletion } from './app-state.js';
4
+ import { createHistory, type History } from './history.js';
5
+
6
+ const UNAVAILABLE = 'WhatsApp local history is unavailable because this runtime lacks the deletion patch. Restart the Metro service parent to install the patched runtime; a page Restart is not enough. Prior history was invalidated to avoid stale deleted messages.';
7
+ const ignore = (): void => undefined;
8
+
9
+ export function createClientHistory(accountId: string, supported = supportsHistoryDeletion()): History {
10
+ const history = createHistory(accountId);
11
+ if (supported) return history;
12
+ try {
13
+ history.invalidate();
14
+ } finally {
15
+ history.close();
16
+ }
17
+ log.warn({ accountId }, UNAVAILABLE);
18
+ return {
19
+ read() { throw new TrainError('whatsapp_history_unavailable', UNAVAILABLE); },
20
+ ingest: ignore,
21
+ ingestSent: ignore,
22
+ alias: ignore,
23
+ clearRange: ignore,
24
+ invalidate: ignore,
25
+ update: ignore,
26
+ deleteMessages: ignore,
27
+ edit: ignore,
28
+ flush: ignore,
29
+ close: ignore,
30
+ };
31
+ }
@@ -0,0 +1,46 @@
1
+ import type makeWASocket from 'baileys';
2
+ import type { fetchLatestWaWebVersion, WAMessage } from 'baileys';
3
+ import type { MemberList } from '@metro-labs/core/stations/types';
4
+ import type { ProfileChange } from '@metro-labs/core/stations/profile';
5
+ import type { SenderProfile } from '@metro-labs/core/stations/sender-profile';
6
+ import type { WhatsAppAccount } from './types.js';
7
+ import type { InboundMessage, ReactionInput } from './format.js';
8
+ import type { SenderFound } from './resolve.js';
9
+ import type { HistoryOptions, HistoryPage } from './history.js';
10
+
11
+ export interface InboundHandlers {
12
+ onMessage(m: InboundMessage, raw: WAMessage): void;
13
+ onReaction(r: ReactionInput): void;
14
+ }
15
+
16
+ export interface WAMedia {
17
+ kind: string;
18
+ path: string;
19
+ mime: string;
20
+ name: string;
21
+ caption?: string;
22
+ }
23
+
24
+ export interface WAClient {
25
+ account: WhatsAppAccount;
26
+ self(): string | null;
27
+ start(handlers: InboundHandlers): Promise<void>;
28
+ read(jid: string, options?: HistoryOptions): Promise<HistoryPage>;
29
+ listMembers(jid: string, limit?: number): Promise<MemberList>;
30
+ sendText(jid: string, text: string, quotedId?: string): Promise<string>;
31
+ sendMedia(jid: string, media: WAMedia, quotedId?: string): Promise<string>;
32
+ sendReaction(jid: string, messageId: string, emoji: string): Promise<void>;
33
+ showTyping(jid: string): Promise<void>;
34
+ editMessage(jid: string, messageId: string, text: string): Promise<void>;
35
+ deleteMessage(jid: string, messageId: string): Promise<void>;
36
+ reuploadMedia(m: WAMessage): Promise<WAMessage>;
37
+ lookupSender(number: string): Promise<SenderFound>;
38
+ setProfile(change: ProfileChange): Promise<void>;
39
+ senderProfile(jid: string): Promise<SenderProfile | null>;
40
+ disconnect(): Promise<void>;
41
+ }
42
+
43
+ export interface ClientRuntime {
44
+ makeSocket: typeof makeWASocket;
45
+ fetchVersion: typeof fetchLatestWaWebVersion;
46
+ }
@@ -11,13 +11,17 @@ import makeWASocket, {
11
11
  import { TrainError } from '@metro-labs/core/train-error';
12
12
  import { errMsg } from '@metro-labs/core/log';
13
13
  import type { WhatsAppAccount } from './types.js';
14
- import type { InboundMessage, ReactionInput } from './format.js';
15
- import type { SenderFound } from './resolve.js';
16
- import type { ProfileChange } from '@metro-labs/core/stations/profile';
14
+ import type { InboundHandlers, WAClient, WAMedia, ClientRuntime } from './client-types.js';
15
+ export type { InboundHandlers, WAClient, WAMedia } from './client-types.js';
17
16
  import { makeProfileCache, nonEmpty, type SenderProfile } from '@metro-labs/core/stations/sender-profile';
18
- import { toInbound, toReaction, type ReactionEvent, type SelfRef } from './parse.js';
17
+ import type { SelfRef } from './parse.js';
18
+ import type { History } from './history.js';
19
+ import { createClientHistory } from './client-history.js';
20
+ import { bindMessages } from './messages.js';
21
+ import { listMembers } from './members.js';
22
+ import { SendExpiry } from './send-expiry.js';
19
23
  import { baileysLogger } from './logger.js';
20
- import { makeNameBook, nameFiles, noteContact, phoneOf, type NameBook } from './names.js';
24
+ import { makeNameBook, nameFiles, phoneOf, type NameBook } from './names.js';
21
25
  import { useAccountAuthState } from './auth-state.js';
22
26
  import { knownKey, makeKeyCache, targetKey, type KeyCache } from './keys.js';
23
27
  import { makeOutbox, type Outbox } from './outbox.js';
@@ -30,41 +34,15 @@ import {
30
34
  type AckWatch,
31
35
  } from './ack.js';
32
36
 
33
- export interface InboundHandlers {
34
- onMessage(m: InboundMessage, raw: WAMessage): void;
35
- onReaction(r: ReactionInput): void;
36
- }
37
-
38
- export interface WAMedia {
39
- kind: string;
40
- path: string;
41
- mime: string;
42
- name: string;
43
- caption?: string;
44
- }
45
-
46
- export interface WAClient {
47
- account: WhatsAppAccount;
48
- self(): string | null;
49
- start(handlers: InboundHandlers): Promise<void>;
50
- sendText(jid: string, text: string, quotedId?: string): Promise<string>;
51
- sendMedia(jid: string, media: WAMedia, quotedId?: string): Promise<string>;
52
- sendReaction(jid: string, messageId: string, emoji: string): Promise<void>;
53
- showTyping(jid: string): Promise<void>;
54
- editMessage(jid: string, messageId: string, text: string): Promise<void>;
55
- deleteMessage(jid: string, messageId: string): Promise<void>;
56
- reuploadMedia(m: WAMessage): Promise<WAMessage>;
57
- lookupSender(number: string): Promise<SenderFound>;
58
- setProfile(change: ProfileChange): Promise<void>;
59
- senderProfile(jid: string): Promise<SenderProfile | null>;
60
- disconnect(): Promise<void>;
61
- }
62
-
63
37
  interface State {
64
38
  account: WhatsAppAccount;
39
+ runtime: ClientRuntime;
40
+ history: History;
41
+ expiry: SendExpiry;
65
42
  handlers?: InboundHandlers;
66
43
  sock?: WASocket;
67
44
  closed: boolean;
45
+ closing?: Promise<void>;
68
46
  openResolve?: () => void;
69
47
  openPromise: Promise<void>;
70
48
  keys: KeyCache;
@@ -91,32 +69,14 @@ function selfRef(st: State, sock: WASocket): SelfRef {
91
69
  }
92
70
 
93
71
  function bindInbound(st: State, sock: WASocket): void {
94
- sock.ev.on('messages.upsert', ({ messages, type }) => {
95
- for (const m of messages) st.keys.remember(m.key);
96
- if (type !== 'notify' || !st.handlers) return;
97
- for (const m of messages) {
98
- if (m.key.fromMe) continue;
99
- const inbound = toInbound(st.account.id, m, selfRef(st, sock));
100
- if (inbound) st.names.note(inbound.senderJid, inbound.pushName);
101
- if (inbound) st.handlers.onMessage(inbound, m);
102
- }
103
- });
104
- sock.ev.on('contacts.upsert', (contacts) => {
105
- for (const c of contacts) noteContact(st.names, c);
106
- });
107
- sock.ev.on('contacts.update', (contacts) => {
108
- for (const c of contacts) noteContact(st.names, c);
109
- });
110
- sock.ev.on('messaging-history.set', ({ contacts, messages }) => {
111
- for (const c of contacts) noteContact(st.names, c);
112
- for (const m of messages) if (m.key.fromMe !== true) st.names.note(m.key.participant ?? m.key.remoteJid, m.pushName);
113
- });
114
- sock.ev.on('messages.reaction', (events: ReactionEvent[]) => {
115
- if (!st.handlers) return;
116
- for (const event of events) {
117
- const reaction = toReaction(st.account.id, event);
118
- if (reaction) st.handlers.onReaction(reaction);
119
- }
72
+ bindMessages(sock, {
73
+ accountId: st.account.id,
74
+ keys: st.keys,
75
+ names: st.names,
76
+ history: st.history,
77
+ current: () => !st.closed && st.sock === sock,
78
+ handlers: () => st.handlers,
79
+ self: () => selfRef(st, sock),
120
80
  });
121
81
  }
122
82
 
@@ -197,19 +157,22 @@ async function connect(st: State): Promise<void> {
197
157
  st.account.credentials,
198
158
  st.account.id,
199
159
  );
200
- const { version, error } = await fetchLatestWaWebVersion({});
160
+ if (st.closed) return;
161
+ const { version, error } = await st.runtime.fetchVersion({});
162
+ if (st.closed) return;
201
163
  if (error) {
202
164
  throw new TrainError(
203
165
  'whatsapp_connect',
204
166
  `failed to fetch WhatsApp web version: ${errMsg(error)}`,
205
167
  );
206
168
  }
207
- const sock = makeWASocket({
169
+ const sock = st.runtime.makeSocket({
208
170
  version,
209
171
  auth: state,
210
172
  browser: Browsers.macOS('Safari'),
211
173
  markOnlineOnConnect: false,
212
174
  syncFullHistory: false,
175
+ emitOwnEvents: false,
213
176
  logger: baileysLogger(st.account.id),
214
177
  getMessage: (key) => Promise.resolve(servedFromOutbox(st, key)),
215
178
  });
@@ -217,6 +180,7 @@ async function connect(st: State): Promise<void> {
217
180
  sock.ev.on('creds.update', () => void saveCreds());
218
181
  bindConnection(st, sock);
219
182
  bindInbound(st, sock);
183
+ st.expiry.bind(sock, () => !st.closed && st.sock === sock);
220
184
  bindDelivery(st, sock);
221
185
  }
222
186
 
@@ -233,7 +197,7 @@ async function lidFor(st: State, sock: WASocket, jid: string): Promise<string |
233
197
 
234
198
  async function ready(st: State): Promise<WASocket> {
235
199
  await st.openPromise;
236
- if (!st.sock) throw new TrainError('whatsapp_call', 'socket not connected');
200
+ if (st.closed || !st.sock) throw new TrainError('whatsapp_call', 'socket not connected');
237
201
  return st.sock;
238
202
  }
239
203
 
@@ -244,10 +208,13 @@ async function send(
244
208
  opts?: SendOpts,
245
209
  ): Promise<string> {
246
210
  const sock = await ready(st);
247
- const sent = await sock.sendMessage(jid, content, opts);
248
- st.keys.remember(sent?.key);
249
- st.outbox.remember(sent?.key, sent?.message);
250
- const messageId = sent?.key.id;
211
+ const expiration = await st.expiry.forSend(sock, jid, content);
212
+ if (st.closed || st.sock !== sock) throw new TrainError('whatsapp_call', 'socket not connected');
213
+ const sent = await sock.sendMessage(jid, content, expiration === undefined ? opts : { ...opts, ephemeralExpiration: expiration });
214
+ const key = sent?.key;
215
+ st.keys.remember(key);
216
+ st.outbox.remember(key, sent?.message);
217
+ const messageId = key?.id;
251
218
  if (!messageId)
252
219
  throw new TrainError(
253
220
  'whatsapp_call',
@@ -256,9 +223,15 @@ async function send(
256
223
  const ack = await st.acks.wait(messageId, ACK_WAIT_MS);
257
224
  const refused = ack ? rejection(ack) : undefined;
258
225
  if (refused) throw refused;
226
+ recordSent(st, sock, sent, expiration);
259
227
  return messageId;
260
228
  }
261
229
 
230
+ function recordSent(st: State, sock: WASocket, sent: WAMessage | undefined, expiration?: number): void {
231
+ if (!sent || st.closed) return;
232
+ st.history.ingestSent([sent], sock.user?.id ? jidNormalizedUser(sock.user.id) : undefined, expiration);
233
+ }
234
+
262
235
  function quotedOpts(st: State, jid: string, quotedId: string): SendOpts {
263
236
  return {
264
237
  quoted: {
@@ -300,9 +273,24 @@ async function readProfile(st: State, jid: string): Promise<SenderProfile> {
300
273
  };
301
274
  }
302
275
 
303
- export function createClient(account: WhatsAppAccount): WAClient {
276
+ async function disconnect(st: State): Promise<void> {
277
+ st.closed = true;
278
+ st.openResolve?.();
279
+ try {
280
+ st.history.close();
281
+ } finally {
282
+ const sock = st.sock;
283
+ st.sock = undefined;
284
+ await sock?.end(undefined);
285
+ }
286
+ }
287
+
288
+ function initialState(account: WhatsAppAccount, runtime: ClientRuntime): State {
304
289
  const st: State = {
305
290
  account,
291
+ runtime,
292
+ history: createClientHistory(account.id),
293
+ expiry: new SendExpiry(account.id),
306
294
  closed: false,
307
295
  openPromise: Promise.resolve(),
308
296
  keys: makeKeyCache(),
@@ -311,6 +299,14 @@ export function createClient(account: WhatsAppAccount): WAClient {
311
299
  names: makeNameBook(nameFiles.path(account.id)),
312
300
  };
313
301
  resetGate(st);
302
+ return st;
303
+ }
304
+
305
+ export function createClient(
306
+ account: WhatsAppAccount,
307
+ runtime: ClientRuntime = { makeSocket: makeWASocket, fetchVersion: fetchLatestWaWebVersion },
308
+ ): WAClient {
309
+ const st = initialState(account, runtime);
314
310
  const senders = makeProfileCache<SenderProfile>((jid) => readProfile(st, jid), {
315
311
  onError: (jid, err) => process.stderr.write(`whatsapp[${st.account.id}] could not read the profile of ${jid}: ${errMsg(err)}\n`),
316
312
  });
@@ -331,6 +327,14 @@ export function createClient(account: WhatsAppAccount): WAClient {
331
327
  );
332
328
  }
333
329
  },
330
+ read(jid, options) {
331
+ const page = st.history.read(jid, options);
332
+ for (const message of page.messages) st.keys.remember(message.key);
333
+ return Promise.resolve(page);
334
+ },
335
+ listMembers(jid, limit) {
336
+ return listMembers(jid, async (node): Promise<unknown> => await (await ready(st)).query(node), st.names, limit);
337
+ },
334
338
  sendText(jid, text, quotedId) {
335
339
  return send(
336
340
  st,
@@ -365,6 +369,7 @@ export function createClient(account: WhatsAppAccount): WAClient {
365
369
  await send(st, jid, {
366
370
  delete: knownKey(st.keys, jid, messageId, true),
367
371
  });
372
+ if (!st.closed) st.history.deleteMessages({ keys: [{ remoteJid: jid, id: messageId }] });
368
373
  },
369
374
  async reuploadMedia(m) {
370
375
  const sock = await ready(st);
@@ -388,13 +393,6 @@ export function createClient(account: WhatsAppAccount): WAClient {
388
393
  }
389
394
  },
390
395
  senderProfile: (jid) => senders.get(jid),
391
- async disconnect() {
392
- st.closed = true;
393
- try {
394
- await st.sock?.end(undefined);
395
- } catch {
396
- st.sock = undefined;
397
- }
398
- },
396
+ disconnect: () => st.closing ??= disconnect(st),
399
397
  };
400
398
  }
@@ -0,0 +1,127 @@
1
+ import { proto, WAMessageStubType, type WAMessage, type WAMessageKey, type WAMessageUpdate } from 'baileys';
2
+ import { clipped, historyContent, historyKey, numberOf, projectMessage } from './history-message.js';
3
+ import { isGroupJid } from './parse.js';
4
+ import { HISTORY_LIMITS, type StoredRow } from './history-types.js';
5
+ import type { HistoryState } from './history-state.js';
6
+
7
+ const earlierExpiry = (a?: number, b?: number): number | undefined => a === undefined ? b : b === undefined ? a : Math.min(a, b);
8
+ const timestamp = (message: WAMessage): number | undefined => {
9
+ const seconds = numberOf(message.messageTimestamp);
10
+ return seconds === undefined ? undefined : seconds * 1000;
11
+ };
12
+
13
+ function removed(event: WAMessageUpdate): boolean {
14
+ return event.key.isViewOnce === true || event.update.message === null || event.update.messageStubType === WAMessageStubType.REVOKE;
15
+ }
16
+
17
+ function acceptedSent(row: StoredRow, sent?: { expiration?: number }): boolean {
18
+ return !sent || (row.key.fromMe === true && (row.expiresAt !== undefined || sent.expiration === 0));
19
+ }
20
+
21
+ function editedRow(old: StoredRow, row: StoredRow, revision: number): StoredRow {
22
+ const expiresAt = earlierExpiry(old.expiresAt, row.expiresAt);
23
+ return { ...old, ...row, truncated: row.truncated ?? false, editedAt: revision, ...(expiresAt === undefined ? {} : { expiresAt }) };
24
+ }
25
+
26
+ export class HistoryEvents {
27
+ constructor(private readonly state: HistoryState) {}
28
+
29
+ private author(old: WAMessageKey, editor: WAMessageKey): boolean {
30
+ if ((old.fromMe === true) !== (editor.fromMe === true)) return false;
31
+ if (!isGroupJid(old.remoteJid ?? '')) return true;
32
+ if (old.participant && editor.participant) return this.state.same(old.participant, editor.participant);
33
+ return old.fromMe === true;
34
+ }
35
+
36
+ private edited(key: WAMessageKey, message: proto.IMessage, revision: number, now: number, selfJid?: string): void {
37
+ const old = this.state.get(key);
38
+ if (!old) {
39
+ this.state.remove(key, now);
40
+ return;
41
+ }
42
+ if (!this.author(old.key, key)) return;
43
+ const row = projectMessage({ key: old.key, message, messageTimestamp: old.at / 1000 }, now, selfJid);
44
+ if (row === 'omit') this.state.remove(old.key, now, old.at);
45
+ if (!row || row === 'omit' || revision > now || revision <= (old.editedAt ?? old.at)) return;
46
+ this.state.put(editedRow(old, row, revision));
47
+ }
48
+
49
+ private protocol(message: WAMessage, now: number, selfJid?: string): boolean {
50
+ const protocol = historyContent(message.message).message?.protocolMessage;
51
+ if (!protocol) return false;
52
+ if (!protocol.key?.id) return true;
53
+ if (protocol.key.remoteJid && !this.state.same(protocol.key.remoteJid, message.key.remoteJid ?? '')) return true;
54
+ const key = historyKey({ ...message.key, id: protocol.key.id });
55
+ if (key) this.applyProtocol(key, protocol, message, now, selfJid);
56
+ return true;
57
+ }
58
+
59
+ private applyProtocol(key: WAMessageKey, protocol: proto.Message.IProtocolMessage, message: WAMessage, now: number, selfJid?: string): void {
60
+ if (protocol.type === proto.Message.ProtocolMessage.Type.REVOKE) this.state.remove(key, now);
61
+ if (protocol.type === proto.Message.ProtocolMessage.Type.MESSAGE_EDIT && protocol.editedMessage) {
62
+ const revision = numberOf(protocol.timestampMs) ?? timestamp(message) ?? 0;
63
+ this.edited(key, protocol.editedMessage, revision, now, selfJid);
64
+ }
65
+ }
66
+
67
+ ingest(message: WAMessage, now: number, selfJid?: string, sent?: { expiration?: number }): void {
68
+ if (!historyKey(message.key)) return;
69
+ const at = timestamp(message);
70
+ if (at !== undefined && this.state.outside(message.key, at, now)) return;
71
+ this.state.observe(message.key, now);
72
+ if (message.messageStubType === WAMessageStubType.REVOKE) {
73
+ this.state.remove(message.key, now, at);
74
+ return;
75
+ }
76
+ if (!this.protocol(message, now, selfJid)) this.content(message, now, selfJid, sent);
77
+ }
78
+
79
+ private content(message: WAMessage, now: number, selfJid?: string, sent?: { expiration?: number }): void {
80
+ const row = projectMessage(message, now, selfJid, sent?.expiration);
81
+ if (row === 'omit') this.state.remove(message.key, now, timestamp(message));
82
+ if (!row || row === 'omit' || this.state.blocked(row.key, row.at) || !acceptedSent(row, sent)) return;
83
+ const old = this.state.get(row.key);
84
+ if (old) this.duplicate(old, row);
85
+ else this.state.put(row);
86
+ }
87
+
88
+ private duplicate(old: StoredRow, row: StoredRow): void {
89
+ const expiresAt = earlierExpiry(old.expiresAt, row.expiresAt);
90
+ if (expiresAt !== undefined) old.expiresAt = expiresAt;
91
+ if (old.key.fromMe === true && old.senderJid === undefined && row.key.fromMe === true && row.senderJid) old.senderJid = row.senderJid;
92
+ }
93
+
94
+ update(event: WAMessageUpdate, now: number, selfJid?: string): void {
95
+ const { key, update } = event;
96
+ if (!historyKey(key)) return;
97
+ const originalAt = this.state.get(key)?.at ?? timestamp({ ...update, key });
98
+ if (removed(event) || update.ephemeralDuration || update.ephemeralStartTimestamp) {
99
+ this.state.remove(key, now, originalAt);
100
+ return;
101
+ }
102
+ this.state.observe(key, now);
103
+ if (!update.message) return;
104
+ const full = { ...update, key };
105
+ if (this.protocol(full, now, selfJid)) return;
106
+ const revision = timestamp(full);
107
+ if (revision) this.edited(key, update.message, revision, now, selfJid);
108
+ else this.refresh(full, now, selfJid);
109
+ }
110
+
111
+ private refresh(message: WAMessage, now: number, selfJid?: string): void {
112
+ const old = this.state.get(message.key);
113
+ const row = projectMessage({ ...message, messageTimestamp: old ? old.at / 1000 : undefined }, now, selfJid);
114
+ if (row === 'omit') this.state.remove(message.key, now, old?.at);
115
+ else if (row && old) this.duplicate(old, row);
116
+ }
117
+
118
+ edit(jid: string, messageId: string, source: string, now: number): void {
119
+ const old = this.state.get({ remoteJid: jid, id: messageId });
120
+ if (!old) {
121
+ this.state.remove({ remoteJid: jid, id: messageId }, now);
122
+ return;
123
+ }
124
+ const text = clipped(source, HISTORY_LIMITS.textBytes);
125
+ this.state.put({ ...old, text, truncated: text !== source, editedAt: now });
126
+ }
127
+ }
@@ -0,0 +1,39 @@
1
+ import { readdirSync, rmSync } from 'node:fs';
2
+ import { basename, dirname, join } from 'node:path';
3
+ import { accountFiles, type AccountFiles } from '@metro-labs/core/stations/account-files';
4
+ import { isRecord } from '@metro-labs/core/is-record';
5
+
6
+ const files = accountFiles('WHATSAPP_TOKEN_DIR', 'whatsapp-history-');
7
+ const TEMP_NAME = /^(whatsapp-history-[A-Za-z0-9_-]*\.json)\.tmp-[1-9]\d*$/;
8
+
9
+ function cleanTemps(dir: string, remove: (name: string) => boolean): void {
10
+ let names: string[];
11
+ try {
12
+ names = readdirSync(dir);
13
+ } catch (err) {
14
+ if (isRecord(err) && err.code === 'ENOENT') return;
15
+ throw err;
16
+ }
17
+ for (const name of names) {
18
+ const original = TEMP_NAME.exec(name)?.[1];
19
+ if (original && remove(original)) rmSync(join(dir, name), { force: true });
20
+ }
21
+ }
22
+
23
+ export function clearHistoryTemps(path: string): void {
24
+ const name = basename(path);
25
+ cleanTemps(dirname(path), (original) => original === name);
26
+ }
27
+
28
+ export const historyFiles: AccountFiles = {
29
+ path: files.path,
30
+ forget(accountId) {
31
+ files.forget(accountId);
32
+ clearHistoryTemps(files.path(accountId));
33
+ },
34
+ forgetExcept(keptAccountIds) {
35
+ files.forgetExcept(keptAccountIds);
36
+ const kept = new Set(keptAccountIds.map((id) => basename(files.path(id))));
37
+ cleanTemps(dirname(files.path('')), (original) => !kept.has(original));
38
+ },
39
+ };
@@ -0,0 +1,58 @@
1
+ import { jidNormalizedUser, type WAMessageKey } from 'baileys';
2
+ import { TrainError } from '@metro-labs/core/train-error';
3
+ import { HISTORY_LIMITS } from './history-types.js';
4
+
5
+ export const normalizedJid = (jid: string): string => jidNormalizedUser(jid);
6
+ const phone = (jid: string): boolean => jid.endsWith('@s.whatsapp.net');
7
+ const lid = (jid: string): boolean => jid.endsWith('@lid');
8
+ const identity = (v: unknown): v is string => typeof v === 'string' && v.length <= 256 && (phone(v) || lid(v));
9
+ const invalid = (): never => { throw new TrainError('whatsapp_history_invalid', 'WhatsApp history aliases are invalid; refusing to discard replay protection'); };
10
+
11
+ export class HistoryIdentity {
12
+ private readonly groups = new Map<string, readonly string[]>();
13
+
14
+ constructor(value: unknown = []) {
15
+ if (!Array.isArray(value)) return invalid();
16
+ for (const group of value) this.restore(group);
17
+ if (this.groups.size > HISTORY_LIMITS.aliases) return invalid();
18
+ }
19
+
20
+ private restore(group: unknown): void {
21
+ if (!Array.isArray(group) || !group.every(identity)) return invalid();
22
+ const pn = group.find(phone);
23
+ const alternate = group.find(lid);
24
+ if (!pn || !alternate) return invalid();
25
+ for (const jid of group) this.alias(jid, phone(jid) ? alternate : pn);
26
+ }
27
+
28
+ get size(): number { return this.groups.size; }
29
+
30
+ known(jid: string): readonly string[] {
31
+ const normalized = normalizedJid(jid);
32
+ return this.groups.get(normalized) ?? [normalized];
33
+ }
34
+
35
+ canonical(jid: string): string { return this.known(jid)[0] ?? normalizedJid(jid); }
36
+
37
+ same(left: string, right: string): boolean { return this.canonical(left) === this.canonical(right); }
38
+
39
+ alias(left: string, right: string): boolean {
40
+ if (!identity(left) || !identity(right)) return false;
41
+ const a = normalizedJid(left);
42
+ const b = normalizedJid(right);
43
+ if (phone(a) === phone(b) || this.same(a, b)) return false;
44
+ const group = [...new Set([...this.known(a), ...this.known(b)])].sort();
45
+ for (const jid of group) this.groups.set(jid, group);
46
+ return true;
47
+ }
48
+
49
+ remember(key: WAMessageKey): boolean {
50
+ const chat = this.alias(key.remoteJid ?? '', key.remoteJidAlt ?? '');
51
+ const sender = this.alias(key.participant ?? '', key.participantAlt ?? '');
52
+ return chat || sender;
53
+ }
54
+
55
+ clear(): void { this.groups.clear(); }
56
+
57
+ serialize(): readonly (readonly string[])[] { return [...new Set(this.groups.values())]; }
58
+ }