@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.
- package/dist/runtime-install.js +22 -5
- package/package.json +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/marketplace/plugin/bin/policy.mjs +1 -1
- package/runtime/node_modules/@metro-labs/core/src/events.ts +45 -1
- package/runtime/node_modules/@metro-labs/core/src/stations/train-boot.ts +22 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agents/map.ts +2 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/policy-gate.ts +3 -3
- package/runtime/node_modules/@metro-labs/daemon/src/monitor/run-event.ts +139 -0
- package/runtime/node_modules/@metro-labs/daemon/src/monitor/run-events.ts +101 -0
- package/runtime/node_modules/@metro-labs/daemon/src/routes/session-apis.ts +2 -0
- package/runtime/node_modules/@metro-labs/daemon/src/stations/runtime-deps.ts +23 -7
- package/runtime/node_modules/@metro-labs/whatsapp/src/actions.ts +32 -1
- package/runtime/node_modules/@metro-labs/whatsapp/src/app-state.ts +49 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/client-history.ts +31 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/client-types.ts +46 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/client.ts +75 -77
- package/runtime/node_modules/@metro-labs/whatsapp/src/history-events.ts +127 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/history-files.ts +39 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/history-identity.ts +58 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/history-message.ts +161 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/history-state.ts +344 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/history-types.ts +91 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/history.ts +195 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/index.ts +1 -1
- package/runtime/node_modules/@metro-labs/whatsapp/src/members.ts +94 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/messages.ts +70 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/send-expiry.ts +65 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/station.ts +4 -0
- package/runtime/patches/5bca2c07d5852d3f58453cff0448dd85da13d3665b61e3d758905b298d432d7c.patch +31 -0
- package/runtime/runtime.json +1 -1
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/events.ts +45 -1
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/train-boot.ts +22 -1
- package/runtime/sdk-runner/runner.json +1 -1
- 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 {
|
|
15
|
-
|
|
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
|
|
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,
|
|
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
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
248
|
-
st.
|
|
249
|
-
|
|
250
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|