@stage-labs/metro 0.1.0-beta.263 → 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/stations/train-boot.ts +22 -1
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/policy-gate.ts +3 -3
- 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/stations/train-boot.ts +22 -1
- package/runtime/sdk-runner/runner.json +1 -1
- package/runtime/stations.json +3 -0
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import { readFileSync, rmSync, statSync } from 'node:fs';
|
|
2
|
+
import { clearHistoryTemps, historyFiles } from './history-files.js';
|
|
3
|
+
import { writeSecure } from '@metro-labs/core/secure-fs';
|
|
4
|
+
import { isRecord } from '@metro-labs/core/is-record';
|
|
5
|
+
import { TrainError } from '@metro-labs/core/train-error';
|
|
6
|
+
import type { proto, WAMessageKey } from 'baileys';
|
|
7
|
+
import { HistoryIdentity, normalizedJid } from './history-identity.js';
|
|
8
|
+
import type { WAMediaRef } from './media.js';
|
|
9
|
+
import { historyJid, historyKey, clipped, numberOf } from './history-message.js';
|
|
10
|
+
import { HISTORY_AGE_MS, HISTORY_LIMITS, newestFirst, slotOf, type StoredRow } from './history-types.js';
|
|
11
|
+
|
|
12
|
+
export { historyFiles } from './history-files.js';
|
|
13
|
+
|
|
14
|
+
const validTime = (v: unknown): v is number => typeof v === 'number' && Number.isSafeInteger(v) && v >= 0 && v < 8.64e15;
|
|
15
|
+
const invalid = (): never => { throw new TrainError('whatsapp_history_invalid', 'WhatsApp local history is invalid; refusing to discard replay protection'); };
|
|
16
|
+
|
|
17
|
+
function mediaKind(value: unknown): value is WAMediaRef['kind'] {
|
|
18
|
+
return value === 'image' || value === 'video' || value === 'audio' || value === 'voice' || value === 'document' || value === 'sticker';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function mediaFromDisk(value: unknown): WAMediaRef {
|
|
22
|
+
if (!isRecord(value)) return invalid();
|
|
23
|
+
const kind = value.kind;
|
|
24
|
+
if (!mediaKind(kind)) return invalid();
|
|
25
|
+
return {
|
|
26
|
+
kind,
|
|
27
|
+
...(typeof value.mime === 'string' ? { mime: clipped(value.mime, 128) } : {}),
|
|
28
|
+
...(typeof value.name === 'string' ? { name: clipped(value.name, 256) } : {}),
|
|
29
|
+
...(validTime(value.bytes) ? { bytes: value.bytes } : {}),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function optionalTime(value: unknown): number | undefined {
|
|
34
|
+
if (value === undefined) return undefined;
|
|
35
|
+
return validTime(value) ? value : invalid();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function metadataFromDisk(value: Record<string, unknown>): Omit<StoredRow, 'key' | 'at' | 'text'> {
|
|
39
|
+
const expiresAt = optionalTime(value.expiresAt);
|
|
40
|
+
const editedAt = optionalTime(value.editedAt);
|
|
41
|
+
const media = value.attachments;
|
|
42
|
+
if (media !== undefined && (!Array.isArray(media) || media.length > 1)) return invalid();
|
|
43
|
+
return {
|
|
44
|
+
...(typeof value.senderJid === 'string' && value.senderJid.length <= 256 ? { senderJid: value.senderJid } : {}),
|
|
45
|
+
...(value.truncated === true ? { truncated: true } : {}),
|
|
46
|
+
...(Array.isArray(media) ? { attachments: media.map(mediaFromDisk) } : {}),
|
|
47
|
+
...(expiresAt === undefined ? {} : { expiresAt }),
|
|
48
|
+
...(editedAt === undefined ? {} : { editedAt }),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function rowFromDisk(value: unknown): StoredRow {
|
|
53
|
+
if (!isRecord(value)) return invalid();
|
|
54
|
+
const key = historyKey(value.key);
|
|
55
|
+
if (!key || key.isViewOnce || !validTime(value.at) || typeof value.text !== 'string') return invalid();
|
|
56
|
+
return { key, at: value.at, text: clipped(value.text, HISTORY_LIMITS.textBytes), ...metadataFromDisk(value) };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function timesFromDisk(value: unknown): Map<string, number> {
|
|
60
|
+
if (!Array.isArray(value)) return invalid();
|
|
61
|
+
const result = new Map<string, number>();
|
|
62
|
+
for (const item of value) {
|
|
63
|
+
if (!Array.isArray(item) || item.length !== 2 || typeof item[0] !== 'string' || item[0].length > 4096 || !validTime(item[1])) return invalid();
|
|
64
|
+
result.set(item[0], item[1]);
|
|
65
|
+
}
|
|
66
|
+
return result;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function rangeKey(jid: string, key: WAMessageKey | null | undefined): WAMessageKey | undefined {
|
|
70
|
+
return historyKey({ ...key, remoteJid: key?.remoteJid ?? jid });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function flagFromDisk(value: unknown): boolean {
|
|
74
|
+
if (value === undefined) return false;
|
|
75
|
+
return typeof value === 'boolean' ? value : invalid();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function unavailableFromDisk(value: unknown = []): Set<string> {
|
|
79
|
+
if (!Array.isArray(value) || value.length > HISTORY_LIMITS.unavailableChats) return invalid();
|
|
80
|
+
const jids: string[] = [];
|
|
81
|
+
for (const jid of value) {
|
|
82
|
+
if (typeof jid !== 'string' || !historyJid(jid)) return invalid();
|
|
83
|
+
jids.push(normalizedJid(jid));
|
|
84
|
+
}
|
|
85
|
+
return new Set(jids);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function readState(path: string): unknown {
|
|
89
|
+
try {
|
|
90
|
+
if (statSync(path).size > HISTORY_LIMITS.bytes) return invalid();
|
|
91
|
+
return JSON.parse(readFileSync(path, 'utf8')) as unknown;
|
|
92
|
+
} catch (err) {
|
|
93
|
+
if (isRecord(err) && err.code === 'ENOENT') return undefined;
|
|
94
|
+
throw err;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export class HistoryState {
|
|
99
|
+
readonly rows = new Map<string, StoredRow>();
|
|
100
|
+
private tombstones = new Map<string, number>();
|
|
101
|
+
private chatFloors = new Map<string, number>();
|
|
102
|
+
private floor: number;
|
|
103
|
+
private written = '';
|
|
104
|
+
private invalidated = false;
|
|
105
|
+
private readonly path: string;
|
|
106
|
+
private identities = new HistoryIdentity();
|
|
107
|
+
private unavailableChats = new Set<string>();
|
|
108
|
+
private unavailableOverflow = false;
|
|
109
|
+
|
|
110
|
+
constructor(private readonly accountId: string) {
|
|
111
|
+
this.path = historyFiles.path(accountId);
|
|
112
|
+
clearHistoryTemps(this.path);
|
|
113
|
+
this.floor = Date.now() - HISTORY_AGE_MS;
|
|
114
|
+
const raw = readState(this.path);
|
|
115
|
+
if (raw === undefined) return;
|
|
116
|
+
if (!isRecord(raw) || raw.version !== 1 || raw.accountId !== accountId || !validTime(raw.floor) || !Array.isArray(raw.rows)) return invalid();
|
|
117
|
+
const invalidated = flagFromDisk(raw.invalidated);
|
|
118
|
+
this.floor = Math.max(this.floor, raw.floor, invalidated ? Date.now() : 0);
|
|
119
|
+
this.identities = new HistoryIdentity(raw.aliases);
|
|
120
|
+
for (const item of raw.rows) {
|
|
121
|
+
const row = rowFromDisk(item);
|
|
122
|
+
this.identities.remember(row.key);
|
|
123
|
+
this.put(row);
|
|
124
|
+
}
|
|
125
|
+
this.tombstones = timesFromDisk(raw.tombstones);
|
|
126
|
+
this.chatFloors = timesFromDisk(raw.chatFloors);
|
|
127
|
+
this.unavailableChats = unavailableFromDisk(raw.unavailableChats);
|
|
128
|
+
this.unavailableOverflow = flagFromDisk(raw.unavailableOverflow);
|
|
129
|
+
this.aliasesChanged(Date.now());
|
|
130
|
+
if (invalidated) this.flush();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
invalidate(now: number): void {
|
|
134
|
+
this.invalidated = true;
|
|
135
|
+
this.floor = Math.max(this.floor, now);
|
|
136
|
+
this.rows.clear();
|
|
137
|
+
this.tombstones.clear();
|
|
138
|
+
this.chatFloors.clear();
|
|
139
|
+
this.unavailableChats.clear();
|
|
140
|
+
this.unavailableOverflow = false;
|
|
141
|
+
this.identities.clear();
|
|
142
|
+
this.flush();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
alias(pn: string, lid: string, now: number): void {
|
|
146
|
+
if (this.identities.alias(pn, lid)) this.aliasesChanged(now);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
observe(key: WAMessageKey, now: number): void {
|
|
150
|
+
if (this.identities.remember(key)) this.aliasesChanged(now);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
same(left: string, right: string): boolean { return this.identities.same(left, right); }
|
|
154
|
+
|
|
155
|
+
assertReadable(jid: string): void {
|
|
156
|
+
if (this.unavailableOverflow || this.identities.known(jid).some((alias) => this.unavailableChats.has(alias)))
|
|
157
|
+
throw new TrainError('whatsapp_history_unavailable', 'WhatsApp local history is unavailable because a chat deletion had no safe message range');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private unavailable(jid: string): void {
|
|
161
|
+
if (this.unavailableOverflow) return;
|
|
162
|
+
this.unavailableChats.add(this.identities.canonical(jid));
|
|
163
|
+
if (this.unavailableChats.size > HISTORY_LIMITS.unavailableChats) {
|
|
164
|
+
this.unavailableOverflow = true;
|
|
165
|
+
this.unavailableChats.clear();
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private forgetAliases(): void {
|
|
170
|
+
this.unavailableOverflow ||= this.unavailableChats.size > 0;
|
|
171
|
+
this.identities.clear();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private aliasesChanged(now: number): void {
|
|
175
|
+
if (this.identities.size > HISTORY_LIMITS.aliases) {
|
|
176
|
+
this.floor = Math.max(this.floor, now);
|
|
177
|
+
this.forgetAliases();
|
|
178
|
+
}
|
|
179
|
+
this.sweep(now);
|
|
180
|
+
const seen = new Map<string, StoredRow>();
|
|
181
|
+
for (const row of this.rows.values()) {
|
|
182
|
+
const id = slotOf({ ...row.key, remoteJid: this.identities.canonical(row.key.remoteJid ?? '') });
|
|
183
|
+
const old = seen.get(id);
|
|
184
|
+
if (!old) { seen.set(id, row); continue; }
|
|
185
|
+
seen.set(id, this.merge(row, old));
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private merge(row: StoredRow, old: StoredRow): StoredRow {
|
|
190
|
+
const kept = (row.editedAt ?? 0) > (old.editedAt ?? 0) ? row : old;
|
|
191
|
+
const dropped = kept === row ? old : row;
|
|
192
|
+
if (dropped.expiresAt !== undefined) kept.expiresAt = Math.min(kept.expiresAt ?? Infinity, dropped.expiresAt);
|
|
193
|
+
this.rows.delete(this.slot(dropped.key));
|
|
194
|
+
return kept;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private slot(key: WAMessageKey): string {
|
|
198
|
+
return slotOf({ ...key, remoteJid: normalizedJid(key.remoteJid ?? '') });
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private slots(key: WAMessageKey): string[] {
|
|
202
|
+
return this.identities.known(key.remoteJid ?? '').map((jid) => slotOf({ remoteJid: jid, id: key.id }));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
get(key: WAMessageKey): StoredRow | undefined {
|
|
206
|
+
for (const slot of this.slots(key)) {
|
|
207
|
+
const row = this.rows.get(slot);
|
|
208
|
+
if (row) return row;
|
|
209
|
+
}
|
|
210
|
+
return undefined;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
put(row: StoredRow): void { this.rows.set(this.slot(row.key), row); }
|
|
214
|
+
|
|
215
|
+
after(jid: string): number {
|
|
216
|
+
return Math.max(this.floor, ...this.identities.known(jid).map((alias) => this.chatFloors.get(alias) ?? 0));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
outside(key: WAMessageKey, at: number, now: number): boolean {
|
|
220
|
+
return at <= Math.max(now - HISTORY_AGE_MS, this.after(key.remoteJid ?? ''), this.after(key.remoteJidAlt ?? ''));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
blocked(key: WAMessageKey, at: number): boolean {
|
|
224
|
+
return this.slots(key).some((slot) => this.tombstones.has(slot)) || at <= this.after(key.remoteJid ?? '');
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
remove(key: WAMessageKey, now: number, at?: number): void {
|
|
228
|
+
const clean = historyKey(key);
|
|
229
|
+
if (!clean) return;
|
|
230
|
+
const originalAt = this.get(clean)?.at ?? at;
|
|
231
|
+
if (originalAt !== undefined && this.outside(clean, originalAt, now)) return;
|
|
232
|
+
this.observe(clean, now);
|
|
233
|
+
let horizon = now;
|
|
234
|
+
for (const slot of this.slots(clean)) {
|
|
235
|
+
horizon = Math.max(horizon, this.rows.get(slot)?.at ?? 0, this.tombstones.get(slot) ?? 0);
|
|
236
|
+
this.rows.delete(slot);
|
|
237
|
+
this.tombstones.delete(slot);
|
|
238
|
+
}
|
|
239
|
+
this.tombstones.set(this.slot(clean), horizon);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
clear(jid: string, cutoff: number): void {
|
|
243
|
+
if (!historyJid(jid)) return;
|
|
244
|
+
this.chatFloors.set(this.identities.canonical(jid), Math.max(cutoff, this.after(jid)));
|
|
245
|
+
for (const [slot, row] of this.rows)
|
|
246
|
+
if (this.same(row.key.remoteJid ?? '', jid) && row.at <= cutoff) this.rows.delete(slot);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
clearRange(jid: string, range: proto.SyncActionValue.ISyncActionMessageRange, now: number): void {
|
|
250
|
+
if (!historyJid(jid)) return;
|
|
251
|
+
const cutoff = (numberOf(range.lastMessageTimestamp) ?? 0) * 1000;
|
|
252
|
+
let usable = cutoff > 0 && cutoff <= now;
|
|
253
|
+
if (usable) this.clear(jid, cutoff);
|
|
254
|
+
for (const item of range.messages ?? []) {
|
|
255
|
+
const key = rangeKey(jid, item.key);
|
|
256
|
+
const cleared = this.clearKey(jid, key, numberOf(item.timestamp), now);
|
|
257
|
+
usable ||= cleared;
|
|
258
|
+
}
|
|
259
|
+
if (!usable) this.unavailable(jid);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
private clearKey(jid: string, key: WAMessageKey | undefined, at: number | undefined, now: number): boolean {
|
|
263
|
+
if (!key || (at !== undefined && at * 1000 > now)) return false;
|
|
264
|
+
if (!this.same(key.remoteJid ?? '', jid) && !this.same(key.remoteJidAlt ?? '', jid)) return false;
|
|
265
|
+
this.observe(key, now);
|
|
266
|
+
this.remove(key, now, at === undefined ? undefined : at * 1000);
|
|
267
|
+
return true;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
private sweep(now: number): void {
|
|
271
|
+
this.floor = Math.max(this.floor, now - HISTORY_AGE_MS);
|
|
272
|
+
for (const [slot, row] of this.rows) {
|
|
273
|
+
if (this.blocked(row.key, row.at)) this.rows.delete(slot);
|
|
274
|
+
else if (row.expiresAt !== undefined && row.expiresAt <= now) this.remove(row.key, now);
|
|
275
|
+
}
|
|
276
|
+
for (const map of [this.tombstones, this.chatFloors])
|
|
277
|
+
for (const [key, cutoff] of map) if (cutoff <= this.floor) map.delete(key);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
private trimMarkers(map: Map<string, number>, max: number): void {
|
|
281
|
+
if (map.size <= max) return;
|
|
282
|
+
const sorted = [...map.entries()].sort((a, b) => a[1] - b[1]);
|
|
283
|
+
for (const [key, cutoff] of sorted.slice(0, map.size - max)) {
|
|
284
|
+
this.floor = Math.max(this.floor, cutoff);
|
|
285
|
+
map.delete(key);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
prune(now: number): void {
|
|
290
|
+
this.sweep(now);
|
|
291
|
+
const sorted = [...this.rows.values()].sort(newestFirst);
|
|
292
|
+
const counts = new Map<string, number>();
|
|
293
|
+
for (const [index, row] of sorted.entries()) {
|
|
294
|
+
const jid = this.identities.canonical(row.key.remoteJid ?? '');
|
|
295
|
+
const count = (counts.get(jid) ?? 0) + 1;
|
|
296
|
+
counts.set(jid, count);
|
|
297
|
+
if (count > HISTORY_LIMITS.rowsPerChat) this.chatFloors.set(jid, Math.max(this.after(jid), row.at));
|
|
298
|
+
if (index >= HISTORY_LIMITS.rows) this.floor = Math.max(this.floor, row.at);
|
|
299
|
+
}
|
|
300
|
+
this.trimMarkers(this.chatFloors, HISTORY_LIMITS.chatFloors);
|
|
301
|
+
this.trimMarkers(this.tombstones, HISTORY_LIMITS.tombstones);
|
|
302
|
+
this.sweep(now);
|
|
303
|
+
this.trimBytes(now);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
private serialize(): string {
|
|
307
|
+
return JSON.stringify({
|
|
308
|
+
version: 1, accountId: this.accountId, floor: this.floor,
|
|
309
|
+
rows: [...this.rows.values()], aliases: this.identities.serialize(),
|
|
310
|
+
...(this.invalidated ? { invalidated: true } : {}),
|
|
311
|
+
unavailableChats: [...this.unavailableChats], unavailableOverflow: this.unavailableOverflow,
|
|
312
|
+
tombstones: [...this.tombstones], chatFloors: [...this.chatFloors],
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
private trimBytes(now: number): void {
|
|
317
|
+
let bytes = Buffer.byteLength(this.serialize());
|
|
318
|
+
if (bytes <= HISTORY_LIMITS.bytes) return;
|
|
319
|
+
for (const row of [...this.rows.values()].sort(newestFirst).reverse()) {
|
|
320
|
+
this.rows.delete(this.slot(row.key));
|
|
321
|
+
this.floor = Math.max(this.floor, row.at);
|
|
322
|
+
bytes -= Buffer.byteLength(JSON.stringify(row)) + 1;
|
|
323
|
+
if (bytes < HISTORY_LIMITS.bytes - 1024) break;
|
|
324
|
+
}
|
|
325
|
+
this.sweep(now);
|
|
326
|
+
if (Buffer.byteLength(this.serialize()) > HISTORY_LIMITS.bytes) {
|
|
327
|
+
this.floor = Math.max(now, this.floor, ...this.tombstones.values(), ...this.chatFloors.values());
|
|
328
|
+
this.forgetAliases();
|
|
329
|
+
this.sweep(now);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
flush(): void {
|
|
334
|
+
this.prune(Date.now());
|
|
335
|
+
const body = this.serialize();
|
|
336
|
+
if (body === this.written) return;
|
|
337
|
+
try {
|
|
338
|
+
writeSecure(this.path, body);
|
|
339
|
+
this.written = body;
|
|
340
|
+
} finally {
|
|
341
|
+
rmSync(`${this.path}.tmp-${process.pid}`, { force: true });
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { proto, WAMessage, WAMessageKey, WAMessageUpdate } from 'baileys';
|
|
2
|
+
import type { WAMediaRef } from './media.js';
|
|
3
|
+
|
|
4
|
+
export const HISTORY_LIMITS = Object.freeze({
|
|
5
|
+
days: 30,
|
|
6
|
+
rows: 5000,
|
|
7
|
+
rowsPerChat: 500,
|
|
8
|
+
bytes: 8 * 1024 * 1024,
|
|
9
|
+
textBytes: 16 * 1024,
|
|
10
|
+
tombstones: 5000,
|
|
11
|
+
chatFloors: 1000,
|
|
12
|
+
unavailableChats: 1000,
|
|
13
|
+
aliases: 5000,
|
|
14
|
+
pageSize: 100,
|
|
15
|
+
pageBytes: 2 * 1024 * 1024,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export const HISTORY_SWEEP_MS = 60_000;
|
|
19
|
+
export const HISTORY_SAVE_MS = 1000;
|
|
20
|
+
export const HISTORY_AGE_MS = HISTORY_LIMITS.days * 86_400_000;
|
|
21
|
+
|
|
22
|
+
export interface HistoryRow {
|
|
23
|
+
key: WAMessageKey;
|
|
24
|
+
messageId: string;
|
|
25
|
+
chatJid: string;
|
|
26
|
+
senderJid?: string;
|
|
27
|
+
fromMe: boolean;
|
|
28
|
+
timestamp: string;
|
|
29
|
+
text: string;
|
|
30
|
+
truncated?: boolean;
|
|
31
|
+
attachments?: WAMediaRef[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface StoredRow {
|
|
35
|
+
key: WAMessageKey;
|
|
36
|
+
at: number;
|
|
37
|
+
senderJid?: string;
|
|
38
|
+
text: string;
|
|
39
|
+
truncated?: boolean;
|
|
40
|
+
attachments?: WAMediaRef[];
|
|
41
|
+
editedAt?: number;
|
|
42
|
+
expiresAt?: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface HistoryOptions {
|
|
46
|
+
limit?: number;
|
|
47
|
+
before?: string;
|
|
48
|
+
since?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface HistoryPage {
|
|
52
|
+
messages: HistoryRow[];
|
|
53
|
+
count: number;
|
|
54
|
+
hasMore: boolean;
|
|
55
|
+
nextBefore?: string;
|
|
56
|
+
coverage: {
|
|
57
|
+
partial: true;
|
|
58
|
+
source: 'local';
|
|
59
|
+
description: string;
|
|
60
|
+
oldest: string | null;
|
|
61
|
+
newest: string | null;
|
|
62
|
+
retained: number;
|
|
63
|
+
retainedAfter: string;
|
|
64
|
+
limits: typeof HISTORY_LIMITS;
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type HistoryDelete = { keys: readonly WAMessageKey[] } | { jid: string; all: true };
|
|
69
|
+
|
|
70
|
+
export interface History {
|
|
71
|
+
read(jid: string, options?: HistoryOptions): HistoryPage;
|
|
72
|
+
ingest(messages: readonly WAMessage[], selfJid?: string): void;
|
|
73
|
+
ingestSent(messages: readonly WAMessage[], selfJid?: string, ephemeralExpiration?: number): void;
|
|
74
|
+
alias(pn: string, lid: string): void;
|
|
75
|
+
clearRange(jid: string, range?: proto.SyncActionValue.ISyncActionMessageRange | null): void;
|
|
76
|
+
invalidate(): void;
|
|
77
|
+
update(updates: readonly WAMessageUpdate[], selfJid?: string): void;
|
|
78
|
+
deleteMessages(event: HistoryDelete): void;
|
|
79
|
+
edit(jid: string, messageId: string, text: string): void;
|
|
80
|
+
flush(): void;
|
|
81
|
+
close(): void;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export const slotOf = (key: WAMessageKey): string => JSON.stringify([key.remoteJid, key.id]);
|
|
85
|
+
|
|
86
|
+
export function newestFirst(a: StoredRow, b: StoredRow): number {
|
|
87
|
+
if (a.at !== b.at) return b.at - a.at;
|
|
88
|
+
const left = slotOf(a.key);
|
|
89
|
+
const right = slotOf(b.key);
|
|
90
|
+
return left === right ? 0 : left < right ? 1 : -1;
|
|
91
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { errMsg, log } from '@metro-labs/core/log';
|
|
2
|
+
import { TrainError } from '@metro-labs/core/train-error';
|
|
3
|
+
import type { proto, WAMessage, WAMessageUpdate } from 'baileys';
|
|
4
|
+
import { HistoryEvents } from './history-events.js';
|
|
5
|
+
import { HistoryState } from './history-state.js';
|
|
6
|
+
import {
|
|
7
|
+
HISTORY_LIMITS, HISTORY_SAVE_MS, HISTORY_SWEEP_MS, newestFirst,
|
|
8
|
+
type History, type HistoryDelete, type HistoryOptions, type HistoryPage, type HistoryRow, type StoredRow,
|
|
9
|
+
} from './history-types.js';
|
|
10
|
+
|
|
11
|
+
export { historyFiles } from './history-state.js';
|
|
12
|
+
export type { History, HistoryOptions, HistoryPage, HistoryRow } from './history-types.js';
|
|
13
|
+
|
|
14
|
+
function shown(row: StoredRow): HistoryRow {
|
|
15
|
+
return {
|
|
16
|
+
key: { ...row.key }, messageId: row.key.id ?? '', chatJid: row.key.remoteJid ?? '',
|
|
17
|
+
fromMe: row.key.fromMe === true, timestamp: new Date(row.at).toISOString(), text: row.text,
|
|
18
|
+
...(row.senderJid ? { senderJid: row.senderJid } : {}),
|
|
19
|
+
...(row.truncated ? { truncated: true } : {}),
|
|
20
|
+
...(row.attachments ? { attachments: row.attachments.map((a) => ({ ...a })) } : {}),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function readBounds(options: HistoryOptions): { limit: number; since: number } {
|
|
25
|
+
const limit = options.limit ?? 50;
|
|
26
|
+
const since = options.since === undefined ? 0 : Date.parse(options.since);
|
|
27
|
+
if (!Number.isInteger(limit) || limit < 1 || !Number.isFinite(since))
|
|
28
|
+
throw new TrainError('bad_request', 'WhatsApp history needs a positive integer limit and a valid since timestamp');
|
|
29
|
+
return { limit: Math.min(limit, HISTORY_LIMITS.pageSize), since };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const rowTime = (row: StoredRow | undefined): string | null => row === undefined ? null : new Date(row.at).toISOString();
|
|
33
|
+
|
|
34
|
+
function boundedPage(result: HistoryPage, rows: StoredRow[], limit: number): HistoryPage {
|
|
35
|
+
let bytes = Buffer.byteLength(JSON.stringify(result));
|
|
36
|
+
for (const row of rows.slice(0, limit)) {
|
|
37
|
+
const message = shown(row);
|
|
38
|
+
const added = Buffer.byteLength(JSON.stringify(message)) + 1;
|
|
39
|
+
const cursor = Buffer.byteLength(JSON.stringify({ count: result.messages.length + 1, hasMore: true, nextBefore: message.messageId }));
|
|
40
|
+
if (bytes + added + cursor > HISTORY_LIMITS.pageBytes - 128 * 1024) break;
|
|
41
|
+
result.messages.push(message);
|
|
42
|
+
bytes += added;
|
|
43
|
+
}
|
|
44
|
+
result.count = result.messages.length;
|
|
45
|
+
result.hasMore = rows.length > result.count;
|
|
46
|
+
if (result.hasMore) result.nextBefore = result.messages.at(-1)?.messageId;
|
|
47
|
+
return result;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function page(state: HistoryState, jid: string, options: HistoryOptions): HistoryPage {
|
|
51
|
+
const { limit, since } = readBounds(options);
|
|
52
|
+
const rows = [...state.rows.values()].filter((row) => state.same(row.key.remoteJid ?? '', jid)).sort(newestFirst);
|
|
53
|
+
const before = options.before === undefined ? -1 : rows.findIndex((row) => row.key.id === options.before);
|
|
54
|
+
if (options.before !== undefined && before < 0)
|
|
55
|
+
throw new TrainError('whatsapp_history_cursor', 'The before message is not retained in this chat. Read without before to restart local pagination.');
|
|
56
|
+
const matching = rows.slice(before + 1).filter((row) => row.at >= since);
|
|
57
|
+
return boundedPage({
|
|
58
|
+
messages: [], count: 0, hasMore: false,
|
|
59
|
+
coverage: {
|
|
60
|
+
partial: true, source: 'local',
|
|
61
|
+
description: 'Only locally observed or synced messages and successful sends with known expiry state. Own sends with unknown expiry are omitted until observed or synced. Gaps remain; no older backfill or media downloads. View-once and unknown-expiry content is omitted. before is exclusive; since is inclusive. Pages reserve output-formatting overhead within the serialized-byte limit. hasMore describes retained local matches only.',
|
|
62
|
+
oldest: rowTime(rows.at(-1)), newest: rowTime(rows[0]),
|
|
63
|
+
retained: rows.length, retainedAfter: new Date(state.after(jid)).toISOString(), limits: HISTORY_LIMITS,
|
|
64
|
+
},
|
|
65
|
+
}, matching, limit);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
class LocalHistory implements History {
|
|
69
|
+
private readonly state: HistoryState;
|
|
70
|
+
private readonly events: HistoryEvents;
|
|
71
|
+
private readonly sweep: ReturnType<typeof setInterval>;
|
|
72
|
+
private timer?: ReturnType<typeof setTimeout>;
|
|
73
|
+
private closed = false;
|
|
74
|
+
|
|
75
|
+
constructor(private readonly accountId: string) {
|
|
76
|
+
this.state = new HistoryState(accountId);
|
|
77
|
+
this.events = new HistoryEvents(this.state);
|
|
78
|
+
this.state.prune(Date.now());
|
|
79
|
+
this.sweep = setInterval(() => { this.saveSafely(); }, HISTORY_SWEEP_MS);
|
|
80
|
+
this.sweep.unref();
|
|
81
|
+
this.schedule();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private assertOpen(): void {
|
|
85
|
+
if (this.closed) throw new TrainError('whatsapp_history_closed', 'WhatsApp local history is closed');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private saveSafely(): void {
|
|
89
|
+
try {
|
|
90
|
+
this.flush();
|
|
91
|
+
} catch (err) {
|
|
92
|
+
log.error({ accountId: this.accountId, err: errMsg(err) }, 'whatsapp: could not save local history');
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private schedule(): void {
|
|
97
|
+
if (this.timer) return;
|
|
98
|
+
this.timer = setTimeout(() => { this.saveSafely(); }, HISTORY_SAVE_MS);
|
|
99
|
+
this.timer.unref();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private changed(now: number): void {
|
|
103
|
+
this.state.prune(now);
|
|
104
|
+
this.schedule();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
read(jid: string, options: HistoryOptions = {}): HistoryPage {
|
|
108
|
+
this.assertOpen();
|
|
109
|
+
this.changed(Date.now());
|
|
110
|
+
this.state.assertReadable(jid);
|
|
111
|
+
return page(this.state, jid, options);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
ingest(messages: readonly WAMessage[], selfJid?: string): void {
|
|
115
|
+
this.ingestAll(messages, selfJid);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
ingestSent(messages: readonly WAMessage[], selfJid?: string, ephemeralExpiration?: number): void {
|
|
119
|
+
this.ingestAll(messages, selfJid, { expiration: ephemeralExpiration });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private ingestAll(messages: readonly WAMessage[], selfJid?: string, sent?: { expiration?: number }): void {
|
|
123
|
+
this.assertOpen();
|
|
124
|
+
const now = Date.now();
|
|
125
|
+
for (const [index, message] of messages.entries()) {
|
|
126
|
+
this.events.ingest(message, now, selfJid, sent);
|
|
127
|
+
if (index % 128 === 127) this.state.prune(now);
|
|
128
|
+
}
|
|
129
|
+
this.changed(now);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
alias(pn: string, lid: string): void {
|
|
133
|
+
this.assertOpen();
|
|
134
|
+
const now = Date.now();
|
|
135
|
+
this.state.alias(pn, lid, now);
|
|
136
|
+
this.changed(now);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
clearRange(jid: string, range?: proto.SyncActionValue.ISyncActionMessageRange | null): void {
|
|
140
|
+
this.assertOpen();
|
|
141
|
+
const now = Date.now();
|
|
142
|
+
this.state.clearRange(jid, range ?? {}, now);
|
|
143
|
+
this.changed(now);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
invalidate(): void {
|
|
147
|
+
this.assertOpen();
|
|
148
|
+
this.state.invalidate(Date.now());
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
update(updates: readonly WAMessageUpdate[], selfJid?: string): void {
|
|
152
|
+
this.assertOpen();
|
|
153
|
+
const now = Date.now();
|
|
154
|
+
for (const [index, update] of updates.entries()) {
|
|
155
|
+
this.events.update(update, now, selfJid);
|
|
156
|
+
if (index % 128 === 127) this.state.prune(now);
|
|
157
|
+
}
|
|
158
|
+
this.changed(now);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
deleteMessages(event: HistoryDelete): void {
|
|
162
|
+
this.assertOpen();
|
|
163
|
+
const now = Date.now();
|
|
164
|
+
if ('keys' in event) {
|
|
165
|
+
for (const [index, key] of event.keys.entries()) {
|
|
166
|
+
this.state.remove(key, now);
|
|
167
|
+
if (index % 128 === 127) this.state.prune(now);
|
|
168
|
+
}
|
|
169
|
+
} else this.state.clear(event.jid, now);
|
|
170
|
+
this.changed(now);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
edit(jid: string, messageId: string, text: string): void {
|
|
174
|
+
this.assertOpen();
|
|
175
|
+
const now = Date.now();
|
|
176
|
+
this.state.prune(now);
|
|
177
|
+
this.events.edit(jid, messageId, text, now);
|
|
178
|
+
this.changed(now);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
flush(): void {
|
|
182
|
+
if (this.timer) clearTimeout(this.timer);
|
|
183
|
+
this.timer = undefined;
|
|
184
|
+
this.state.flush();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
close(): void {
|
|
188
|
+
if (this.closed) return;
|
|
189
|
+
clearInterval(this.sweep);
|
|
190
|
+
this.closed = true;
|
|
191
|
+
this.flush();
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export const createHistory = (accountId: string): History => new LocalHistory(accountId);
|
|
@@ -4,4 +4,4 @@ import { createClient } from './client.js';
|
|
|
4
4
|
import { startInbound } from './inbound.js';
|
|
5
5
|
import { makeHandleCall } from './actions.js';
|
|
6
6
|
|
|
7
|
-
runClientTrain({ station: 'whatsapp', accounts, loadAccounts, createClient, startInbound, makeHandleCall });
|
|
7
|
+
runClientTrain({ station: 'whatsapp', accounts, loadAccounts, createClient, startInbound, makeHandleCall, disconnectClient: (client) => client.disconnect() });
|