@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,161 @@
|
|
|
1
|
+
import { isRecord } from '@metro-labs/core/is-record';
|
|
2
|
+
import type { WAMessage, WAMessageKey, proto } from 'baileys';
|
|
3
|
+
import { mediaRefIn, type WAMediaRef } from './media.js';
|
|
4
|
+
import { extractText, isGroupJid, isPrivateJid } from './parse.js';
|
|
5
|
+
import { HISTORY_LIMITS, type StoredRow } from './history-types.js';
|
|
6
|
+
|
|
7
|
+
const KEY_STRINGS = [
|
|
8
|
+
'remoteJid', 'id', 'participant', 'remoteJidAlt', 'remoteJidUsername',
|
|
9
|
+
'participantAlt', 'participantUsername', 'server_id', 'addressingMode',
|
|
10
|
+
] as const;
|
|
11
|
+
const CONTEXT_NODES = [
|
|
12
|
+
'extendedTextMessage', 'imageMessage', 'videoMessage', 'audioMessage',
|
|
13
|
+
'documentMessage', 'stickerMessage',
|
|
14
|
+
] as const;
|
|
15
|
+
|
|
16
|
+
type Timestamp = number | { toNumber(): number } | null | undefined;
|
|
17
|
+
|
|
18
|
+
export function numberOf(value: Timestamp): number | undefined {
|
|
19
|
+
const n = typeof value === 'number' ? value : typeof value?.toNumber === 'function' ? value.toNumber() : undefined;
|
|
20
|
+
return n !== undefined && Number.isSafeInteger(n) && n > 0 ? n : undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function clipped(text: string, bytes: number): string {
|
|
24
|
+
if (Buffer.byteLength(text) <= bytes) return text;
|
|
25
|
+
const buffer = Buffer.from(text);
|
|
26
|
+
let end = bytes;
|
|
27
|
+
while (end > 0 && ((buffer[end] ?? 0) & 0xc0) === 0x80) end--;
|
|
28
|
+
return buffer.subarray(0, end).toString('utf8');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function keyStrings(value: Record<string, unknown>): [string, string | boolean | null][] | undefined {
|
|
32
|
+
const fields: [string, string | boolean | null][] = [];
|
|
33
|
+
for (const field of KEY_STRINGS) {
|
|
34
|
+
const v = value[field];
|
|
35
|
+
if (v === undefined) continue;
|
|
36
|
+
if (v !== null && (typeof v !== 'string' || v.length > 256)) return undefined;
|
|
37
|
+
fields.push([field, v]);
|
|
38
|
+
}
|
|
39
|
+
return fields;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const historyJid = (jid: string): boolean => jid.length <= 256 && (isPrivateJid(jid) || isGroupJid(jid));
|
|
43
|
+
|
|
44
|
+
function identified(key: WAMessageKey): boolean {
|
|
45
|
+
return Boolean(key.id) && historyJid(key.remoteJid ?? '');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function historyKey(value: unknown): WAMessageKey | undefined {
|
|
49
|
+
if (!isRecord(value)) return undefined;
|
|
50
|
+
const fields = keyStrings(value);
|
|
51
|
+
if (!fields) return undefined;
|
|
52
|
+
for (const field of ['fromMe', 'isViewOnce']) {
|
|
53
|
+
const v = value[field];
|
|
54
|
+
if (v === undefined) continue;
|
|
55
|
+
if (v !== null && typeof v !== 'boolean') return undefined;
|
|
56
|
+
fields.push([field, v]);
|
|
57
|
+
}
|
|
58
|
+
const key: WAMessageKey = Object.fromEntries(fields);
|
|
59
|
+
return identified(key) ? key : undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface Content {
|
|
63
|
+
message?: proto.IMessage;
|
|
64
|
+
ephemeral: boolean;
|
|
65
|
+
forbidden: boolean;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function privateContent(message: proto.IMessage): boolean {
|
|
69
|
+
return [message.viewOnceMessage, message.viewOnceMessageV2, message.viewOnceMessageV2Extension,
|
|
70
|
+
message.imageMessage?.viewOnce, message.videoMessage?.viewOnce, message.audioMessage?.viewOnce,
|
|
71
|
+
message.messageContextInfo?.messageAddOnExpiryType].some(Boolean);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function innerOf(message: proto.IMessage): proto.IMessage | undefined {
|
|
75
|
+
const wrappers = [message.ephemeralMessage, message.deviceSentMessage, message.documentWithCaptionMessage, message.editedMessage];
|
|
76
|
+
return wrappers.find((wrapper) => wrapper?.message)?.message ?? undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function historyContent(message: proto.IMessage | null | undefined): Content {
|
|
80
|
+
if (!message) return { ephemeral: false, forbidden: false };
|
|
81
|
+
let current = message;
|
|
82
|
+
let ephemeral = false;
|
|
83
|
+
for (let depth = 0; current && depth < 12; depth++) {
|
|
84
|
+
if (privateContent(current)) return { ephemeral, forbidden: true };
|
|
85
|
+
ephemeral ||= Boolean(current.ephemeralMessage);
|
|
86
|
+
const inner = innerOf(current);
|
|
87
|
+
if (!inner) return { message: current, ephemeral, forbidden: false };
|
|
88
|
+
current = inner;
|
|
89
|
+
}
|
|
90
|
+
return { ephemeral, forbidden: true };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function contextOf(message: proto.IMessage): proto.IContextInfo | undefined {
|
|
94
|
+
for (const field of CONTEXT_NODES) {
|
|
95
|
+
const context = message[field]?.contextInfo;
|
|
96
|
+
if (context) return context;
|
|
97
|
+
}
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const durationGiven = (n: number | null | undefined): n is number => n !== undefined && n !== null && n !== 0;
|
|
102
|
+
|
|
103
|
+
function expiry(message: WAMessage, content: Content, at: number, expiration?: number): number | false | undefined {
|
|
104
|
+
const ctx = content.message ? contextOf(content.message) : undefined;
|
|
105
|
+
const duration = [message.ephemeralDuration, ctx?.expiration, expiration].filter(durationGiven);
|
|
106
|
+
if (duration.some((n) => !Number.isFinite(n) || n < 0)) return false;
|
|
107
|
+
const ephemeral = [content.ephemeral, message.ephemeralStartTimestamp, ctx?.disappearingMode, ctx?.ephemeralSettingTimestamp].some(Boolean);
|
|
108
|
+
if (!duration.length) return ephemeral ? false : undefined;
|
|
109
|
+
const start = numberOf(message.ephemeralStartTimestamp);
|
|
110
|
+
const base = start === undefined ? at : Math.min(at, start * 1000);
|
|
111
|
+
const end = base + Math.min(...duration) * 1000;
|
|
112
|
+
return Number.isSafeInteger(end) ? end : false;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function attachment(message: proto.IMessage): WAMediaRef | undefined {
|
|
116
|
+
const media = mediaRefIn(message);
|
|
117
|
+
if (!media) return undefined;
|
|
118
|
+
return {
|
|
119
|
+
kind: media.kind,
|
|
120
|
+
...(media.mime ? { mime: clipped(media.mime, 128) } : {}),
|
|
121
|
+
...(media.name ? { name: clipped(media.name, 256) } : {}),
|
|
122
|
+
...(Number.isSafeInteger(media.bytes) && (media.bytes ?? -1) >= 0 ? { bytes: media.bytes } : {}),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function senderOf(key: WAMessageKey, selfJid?: string): string | undefined {
|
|
127
|
+
const sender = key.fromMe ? selfJid : isGroupJid(key.remoteJid ?? '') ? key.participant : key.remoteJid;
|
|
128
|
+
return typeof sender === 'string' && sender.length <= 256 ? sender : undefined;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function retainedExpiry(value: number | false | undefined, now: number): value is number | undefined {
|
|
132
|
+
return value !== false && (value === undefined || value > now);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function projectMessage(message: WAMessage, now: number, selfJid?: string, expiration?: number): StoredRow | 'omit' | undefined {
|
|
136
|
+
const key = historyKey(message.key);
|
|
137
|
+
if (!key) return undefined;
|
|
138
|
+
const content = historyContent(message.message);
|
|
139
|
+
if (key.isViewOnce || content.forbidden) return 'omit';
|
|
140
|
+
const at = (numberOf(message.messageTimestamp) ?? 0) * 1000;
|
|
141
|
+
if (!at || at > now) return undefined;
|
|
142
|
+
const expiresAt = expiry(message, content, at, expiration);
|
|
143
|
+
if (!retainedExpiry(expiresAt, now)) return 'omit';
|
|
144
|
+
if (!content.message) return undefined;
|
|
145
|
+
return projectBody(key, content.message, at, selfJid, expiresAt);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function projectBody(key: WAMessageKey, message: proto.IMessage, at: number, selfJid?: string, expiresAt?: number): StoredRow | undefined {
|
|
149
|
+
const source = extractText(message);
|
|
150
|
+
const media = attachment(message);
|
|
151
|
+
if (!source && !media) return undefined;
|
|
152
|
+
const text = clipped(source, HISTORY_LIMITS.textBytes);
|
|
153
|
+
const senderJid = senderOf(key, selfJid);
|
|
154
|
+
return {
|
|
155
|
+
key, at, text,
|
|
156
|
+
...(senderJid ? { senderJid } : {}),
|
|
157
|
+
...(text !== source ? { truncated: true } : {}),
|
|
158
|
+
...(media ? { attachments: [media] } : {}),
|
|
159
|
+
...(expiresAt === undefined ? {} : { expiresAt }),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
@@ -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
|
+
}
|