@prototypevip/baileys 0.0.5 → 0.0.7
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/lib/AntiBan/antiban.js +922 -0
- package/lib/AntiBan/banRecoveryOrchestrator.js +284 -0
- package/lib/AntiBan/contactGraph.js +236 -0
- package/lib/AntiBan/contentVariator.js +150 -0
- package/lib/AntiBan/credsSnapshot.js +121 -0
- package/lib/AntiBan/deliveryTracker.js +107 -0
- package/lib/AntiBan/deviceFingerprint.js +106 -0
- package/lib/AntiBan/fleetEventStore.js +157 -0
- package/lib/AntiBan/groupOperationGuard.js +116 -0
- package/lib/AntiBan/health.js +207 -0
- package/lib/AntiBan/humanEntropy.js +337 -0
- package/lib/AntiBan/index.js +77 -0
- package/lib/AntiBan/instanceCoordinator.js +175 -0
- package/lib/AntiBan/jidCanonicalizer.js +256 -0
- package/lib/AntiBan/jidCircuitBreaker.js +186 -0
- package/lib/AntiBan/legitimacySignalInjector.js +231 -0
- package/lib/AntiBan/lidFirstResolver.js +174 -0
- package/lib/AntiBan/lidResolver.js +324 -0
- package/lib/AntiBan/messageQueue.js +187 -0
- package/lib/AntiBan/messageRecovery.js +283 -0
- package/lib/AntiBan/messageTypeRegistry.js +369 -0
- package/lib/AntiBan/observability.js +145 -0
- package/lib/AntiBan/patch.js +302 -0
- package/lib/AntiBan/persist.js +87 -0
- package/lib/AntiBan/presenceChoreographer.js +430 -0
- package/lib/AntiBan/presets.js +105 -0
- package/lib/AntiBan/profiles.js +31 -0
- package/lib/AntiBan/proxyRotator.js +306 -0
- package/lib/AntiBan/rateLimiter.js +261 -0
- package/lib/AntiBan/readReceiptVariance.js +88 -0
- package/lib/AntiBan/reconnectThrottle.js +180 -0
- package/lib/AntiBan/replyRatio.js +161 -0
- package/lib/AntiBan/reputationVoucher.js +373 -0
- package/lib/AntiBan/retryReason.js +91 -0
- package/lib/AntiBan/retryTracker.js +172 -0
- package/lib/AntiBan/scheduler.js +111 -0
- package/lib/AntiBan/sessionFingerprint.js +248 -0
- package/lib/AntiBan/sessionStability.js +330 -0
- package/lib/AntiBan/stateAdapter.js +73 -0
- package/lib/AntiBan/stateExport.js +152 -0
- package/lib/AntiBan/stealthConnect.js +130 -0
- package/lib/AntiBan/timelockGuard.js +181 -0
- package/lib/AntiBan/topologyThrottler.js +339 -0
- package/lib/AntiBan/warmup.js +140 -0
- package/lib/AntiBan/webhooks.js +80 -0
- package/lib/AntiBan/wrapper.js +456 -0
- package/lib/Socket/chats.js +29 -4
- package/lib/Socket/groups.js +7 -2
- package/lib/Socket/index.js +68 -3
- package/lib/Socket/messages-send.js +44 -8
- package/lib/Socket/socket.js +2 -2
- package/lib/Store/prototype-store.js +10 -39
- package/lib/index.js +5 -0
- package/package.json +7 -3
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
const DEFAULT_CONFIG = {
|
|
3
|
+
maxTrackedChats: 1000,
|
|
4
|
+
maxGapMs: 30 * 60_000, // 30 minutes
|
|
5
|
+
persistDebounceMs: 2_000,
|
|
6
|
+
onGapFilled: () => { },
|
|
7
|
+
logger: {
|
|
8
|
+
info: () => { },
|
|
9
|
+
warn: () => { },
|
|
10
|
+
error: () => { },
|
|
11
|
+
},
|
|
12
|
+
};
|
|
13
|
+
export function messageRecovery(sock, config) {
|
|
14
|
+
const cfg = { ...DEFAULT_CONFIG, ...config };
|
|
15
|
+
const logger = cfg.logger;
|
|
16
|
+
// State
|
|
17
|
+
const lastSeen = new Map();
|
|
18
|
+
let disconnectedAt = null;
|
|
19
|
+
let totalRecovered = 0;
|
|
20
|
+
let lastReconnectAt = null;
|
|
21
|
+
let lastGapMs = null;
|
|
22
|
+
// Persistence
|
|
23
|
+
let persistTimer = null;
|
|
24
|
+
let loggedFetchWarning = false;
|
|
25
|
+
// Load persisted state on startup (synchronous — seeds lastSeen before first event)
|
|
26
|
+
if (cfg.persistPath) {
|
|
27
|
+
loadPersistence();
|
|
28
|
+
}
|
|
29
|
+
// Listen to messages.upsert to track lastSeen
|
|
30
|
+
const messagesListener = sock.ev.process
|
|
31
|
+
? setupProcessListener()
|
|
32
|
+
: setupLegacyListener();
|
|
33
|
+
// Listen to connection.update for disconnect/reconnect
|
|
34
|
+
const connectionListener = (update) => {
|
|
35
|
+
if (update.connection === 'close') {
|
|
36
|
+
disconnectedAt = Date.now();
|
|
37
|
+
logger.info?.(`[messageRecovery] Disconnected at ${new Date(disconnectedAt).toISOString()}`);
|
|
38
|
+
}
|
|
39
|
+
if (update.connection === 'open' && disconnectedAt !== null) {
|
|
40
|
+
// Trigger recovery
|
|
41
|
+
void recoverMessages();
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
sock.ev.on('connection.update', connectionListener);
|
|
45
|
+
// Setup process-based listener (Baileys >= late 2022)
|
|
46
|
+
function setupProcessListener() {
|
|
47
|
+
const listener = async (events) => {
|
|
48
|
+
if (events['messages.upsert']) {
|
|
49
|
+
const { messages, type } = events['messages.upsert'];
|
|
50
|
+
// Only track real-time messages, skip 'append' to avoid loops
|
|
51
|
+
if (type === 'notify') {
|
|
52
|
+
for (const msg of messages || []) {
|
|
53
|
+
trackMessage(msg);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
sock.ev.process(listener);
|
|
59
|
+
return listener;
|
|
60
|
+
}
|
|
61
|
+
// Setup legacy on() listener (older Baileys)
|
|
62
|
+
function setupLegacyListener() {
|
|
63
|
+
const listener = (upsert) => {
|
|
64
|
+
const { messages, type } = upsert;
|
|
65
|
+
if (type === 'notify') {
|
|
66
|
+
for (const msg of messages || []) {
|
|
67
|
+
trackMessage(msg);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
sock.ev.on('messages.upsert', listener);
|
|
72
|
+
return listener;
|
|
73
|
+
}
|
|
74
|
+
function trackMessage(msg) {
|
|
75
|
+
const jid = msg.key?.remoteJid;
|
|
76
|
+
const messageId = msg.key?.id;
|
|
77
|
+
const timestamp = msg.messageTimestamp;
|
|
78
|
+
if (!jid || !messageId || !timestamp)
|
|
79
|
+
return;
|
|
80
|
+
// Skip self-messages to reduce noise
|
|
81
|
+
if (msg.key?.fromMe)
|
|
82
|
+
return;
|
|
83
|
+
const now = Date.now();
|
|
84
|
+
lastSeen.set(jid, {
|
|
85
|
+
messageId,
|
|
86
|
+
timestamp: typeof timestamp === 'number' ? timestamp : parseInt(timestamp, 10),
|
|
87
|
+
lastTouchedAt: now,
|
|
88
|
+
});
|
|
89
|
+
// Evict oldest if over capacity
|
|
90
|
+
if (lastSeen.size > cfg.maxTrackedChats) {
|
|
91
|
+
evictOldest();
|
|
92
|
+
}
|
|
93
|
+
// Debounced persist
|
|
94
|
+
schedulePersist();
|
|
95
|
+
}
|
|
96
|
+
function evictOldest() {
|
|
97
|
+
let oldestJid = null;
|
|
98
|
+
let oldestTime = Infinity;
|
|
99
|
+
for (const [jid, entry] of lastSeen) {
|
|
100
|
+
if (entry.lastTouchedAt < oldestTime) {
|
|
101
|
+
oldestTime = entry.lastTouchedAt;
|
|
102
|
+
oldestJid = jid;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (oldestJid) {
|
|
106
|
+
lastSeen.delete(oldestJid);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
async function recoverMessages() {
|
|
110
|
+
const recoveryStartMs = Date.now();
|
|
111
|
+
const gapMs = recoveryStartMs - disconnectedAt;
|
|
112
|
+
logger.info?.(`[messageRecovery] Reconnected after ${(gapMs / 1000).toFixed(1)}s`);
|
|
113
|
+
if (gapMs > cfg.maxGapMs) {
|
|
114
|
+
logger.warn?.(`[messageRecovery] Gap too large (${(gapMs / 1000).toFixed(0)}s > ${(cfg.maxGapMs / 1000).toFixed(0)}s) — skipping recovery`);
|
|
115
|
+
disconnectedAt = null;
|
|
116
|
+
lastGapMs = gapMs;
|
|
117
|
+
await cfg.onGapTooLarge?.(gapMs);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
let recovered = 0;
|
|
121
|
+
const chatsToRecover = Array.from(lastSeen.entries());
|
|
122
|
+
// Check if fetchMessageHistory exists
|
|
123
|
+
if (typeof sock.fetchMessageHistory !== 'function') {
|
|
124
|
+
if (!loggedFetchWarning) {
|
|
125
|
+
logger.warn?.(`[messageRecovery] sock.fetchMessageHistory not available — recovery disabled. Baileys version may not support history fetch. User must implement manual reconciliation.`);
|
|
126
|
+
loggedFetchWarning = true;
|
|
127
|
+
}
|
|
128
|
+
disconnectedAt = null;
|
|
129
|
+
lastReconnectAt = new Date();
|
|
130
|
+
lastGapMs = gapMs;
|
|
131
|
+
await cfg.onRecoveryComplete?.({
|
|
132
|
+
chats: 0,
|
|
133
|
+
recovered: 0,
|
|
134
|
+
durationMs: Date.now() - recoveryStartMs,
|
|
135
|
+
});
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
for (const [jid, lastSeenEntry] of chatsToRecover) {
|
|
139
|
+
try {
|
|
140
|
+
// Fetch messages newer than lastSeen timestamp.
|
|
141
|
+
// Baileys v7 may have changed this signature — fall back gracefully on any error.
|
|
142
|
+
let messages;
|
|
143
|
+
try {
|
|
144
|
+
const result = await sock.fetchMessageHistory(jid, 50, { before: undefined });
|
|
145
|
+
messages = Array.isArray(result) ? result : [];
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
if (!loggedFetchWarning) {
|
|
149
|
+
logger.warn?.(`[messageRecovery] sock.fetchMessageHistory failed — signature may have changed in this Baileys version. Recovery skipped for this reconnect.`);
|
|
150
|
+
loggedFetchWarning = true;
|
|
151
|
+
}
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (!messages || !Array.isArray(messages))
|
|
155
|
+
continue;
|
|
156
|
+
// Filter to messages newer than lastSeen
|
|
157
|
+
const gapMessages = messages.filter((msg) => {
|
|
158
|
+
const ts = msg.messageTimestamp;
|
|
159
|
+
if (!ts)
|
|
160
|
+
return false;
|
|
161
|
+
const msgTs = typeof ts === 'number' ? ts : parseInt(ts, 10);
|
|
162
|
+
return msgTs > lastSeenEntry.timestamp;
|
|
163
|
+
});
|
|
164
|
+
// Sort chronologically (oldest first for replay)
|
|
165
|
+
gapMessages.sort((a, b) => {
|
|
166
|
+
const aTs = typeof a.messageTimestamp === 'number' ? a.messageTimestamp : parseInt(a.messageTimestamp, 10);
|
|
167
|
+
const bTs = typeof b.messageTimestamp === 'number' ? b.messageTimestamp : parseInt(b.messageTimestamp, 10);
|
|
168
|
+
return aTs - bTs;
|
|
169
|
+
});
|
|
170
|
+
// Re-emit gap messages
|
|
171
|
+
for (const msg of gapMessages) {
|
|
172
|
+
await cfg.onGapFilled(msg, jid);
|
|
173
|
+
recovered++;
|
|
174
|
+
// Update lastSeen to newest delivered
|
|
175
|
+
const msgTs = typeof msg.messageTimestamp === 'number' ? msg.messageTimestamp : parseInt(msg.messageTimestamp, 10);
|
|
176
|
+
if (msgTs > lastSeenEntry.timestamp) {
|
|
177
|
+
lastSeenEntry.timestamp = msgTs;
|
|
178
|
+
lastSeenEntry.messageId = msg.key?.id || lastSeenEntry.messageId;
|
|
179
|
+
lastSeenEntry.lastTouchedAt = Date.now();
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (gapMessages.length > 0) {
|
|
183
|
+
logger.info?.(`[messageRecovery] Recovered ${gapMessages.length} messages from ${jid}`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
catch (err) {
|
|
187
|
+
logger.error?.(`[messageRecovery] Failed to recover from ${jid}: ${err.message}`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
totalRecovered += recovered;
|
|
191
|
+
lastReconnectAt = new Date();
|
|
192
|
+
lastGapMs = gapMs;
|
|
193
|
+
disconnectedAt = null;
|
|
194
|
+
logger.info?.(`[messageRecovery] Recovery complete: ${recovered} messages across ${chatsToRecover.length} chats in ${Date.now() - recoveryStartMs}ms`);
|
|
195
|
+
await cfg.onRecoveryComplete?.({
|
|
196
|
+
chats: chatsToRecover.length,
|
|
197
|
+
recovered,
|
|
198
|
+
durationMs: Date.now() - recoveryStartMs,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
function schedulePersist() {
|
|
202
|
+
if (!cfg.persistPath)
|
|
203
|
+
return;
|
|
204
|
+
if (persistTimer) {
|
|
205
|
+
clearTimeout(persistTimer);
|
|
206
|
+
}
|
|
207
|
+
persistTimer = setTimeout(() => {
|
|
208
|
+
void flushPersistence();
|
|
209
|
+
}, cfg.persistDebounceMs);
|
|
210
|
+
}
|
|
211
|
+
async function flushPersistence() {
|
|
212
|
+
if (!cfg.persistPath)
|
|
213
|
+
return;
|
|
214
|
+
try {
|
|
215
|
+
const fs = await import('fs/promises');
|
|
216
|
+
const data = {};
|
|
217
|
+
for (const [jid, entry] of lastSeen) {
|
|
218
|
+
data[jid] = {
|
|
219
|
+
id: entry.messageId,
|
|
220
|
+
timestamp: entry.timestamp,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
await fs.writeFile(cfg.persistPath, JSON.stringify(data, null, 2), 'utf-8');
|
|
224
|
+
}
|
|
225
|
+
catch (err) {
|
|
226
|
+
logger.error?.(`[messageRecovery] Failed to persist state: ${err.message}`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
function loadPersistence() {
|
|
230
|
+
if (!cfg.persistPath)
|
|
231
|
+
return;
|
|
232
|
+
try {
|
|
233
|
+
if (!existsSync(cfg.persistPath))
|
|
234
|
+
return;
|
|
235
|
+
const raw = readFileSync(cfg.persistPath, 'utf-8');
|
|
236
|
+
const data = JSON.parse(raw);
|
|
237
|
+
for (const [jid, entry] of Object.entries(data)) {
|
|
238
|
+
lastSeen.set(jid, {
|
|
239
|
+
messageId: entry.id,
|
|
240
|
+
timestamp: entry.timestamp,
|
|
241
|
+
lastTouchedAt: Date.now(),
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
logger.info?.(`[messageRecovery] Loaded ${lastSeen.size} entries from ${cfg.persistPath}`);
|
|
245
|
+
}
|
|
246
|
+
catch (err) {
|
|
247
|
+
logger.warn?.(`[messageRecovery] Failed to load persisted state: ${err.message}`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
// Public API
|
|
251
|
+
return {
|
|
252
|
+
async stop() {
|
|
253
|
+
// Remove listeners
|
|
254
|
+
sock.ev.off('connection.update', connectionListener);
|
|
255
|
+
if (!sock.ev.process) {
|
|
256
|
+
sock.ev.off('messages.upsert', messagesListener);
|
|
257
|
+
}
|
|
258
|
+
// Flush persistence
|
|
259
|
+
if (persistTimer) {
|
|
260
|
+
clearTimeout(persistTimer);
|
|
261
|
+
persistTimer = null;
|
|
262
|
+
}
|
|
263
|
+
await flushPersistence();
|
|
264
|
+
logger.info?.(`[messageRecovery] Stopped — total recovered: ${totalRecovered}`);
|
|
265
|
+
},
|
|
266
|
+
markSeen(chatJid, messageId, timestamp) {
|
|
267
|
+
lastSeen.set(chatJid, {
|
|
268
|
+
messageId,
|
|
269
|
+
timestamp,
|
|
270
|
+
lastTouchedAt: Date.now(),
|
|
271
|
+
});
|
|
272
|
+
schedulePersist();
|
|
273
|
+
},
|
|
274
|
+
getStats() {
|
|
275
|
+
return {
|
|
276
|
+
trackedChats: lastSeen.size,
|
|
277
|
+
totalRecovered,
|
|
278
|
+
lastReconnectAt,
|
|
279
|
+
lastGapMs,
|
|
280
|
+
};
|
|
281
|
+
},
|
|
282
|
+
};
|
|
283
|
+
}
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Message Type Registry — Track message types with priority, legitimacy, and engagement
|
|
3
|
+
*
|
|
4
|
+
* Developers register message types upfront with priority and legitimacy requirements.
|
|
5
|
+
* Library tracks engagement metrics per type and enforces provenance on critical sends.
|
|
6
|
+
*
|
|
7
|
+
* Features:
|
|
8
|
+
* - Type registration (immutable after first send)
|
|
9
|
+
* - Provenance validation for critical messages
|
|
10
|
+
* - Per-type engagement tracking (sent/delivered/read/replied/blocked)
|
|
11
|
+
* - Per-pool rate limiting
|
|
12
|
+
* - Warning emission (NO auto-throttling)
|
|
13
|
+
*/
|
|
14
|
+
import { RateLimiter } from './rateLimiter.js';
|
|
15
|
+
const DEFAULT_POOL_CONFIG = {
|
|
16
|
+
maxPerMinute: 8,
|
|
17
|
+
maxPerHour: 200,
|
|
18
|
+
maxPerDay: 1500,
|
|
19
|
+
minDelayMs: 1500,
|
|
20
|
+
maxDelayMs: 5000,
|
|
21
|
+
newChatDelayMs: 3000,
|
|
22
|
+
maxIdenticalMessages: 3,
|
|
23
|
+
burstAllowance: 3,
|
|
24
|
+
identicalMessageWindowMs: 3600000,
|
|
25
|
+
};
|
|
26
|
+
export class MessageTypeRegistry {
|
|
27
|
+
types = new Map();
|
|
28
|
+
stats = new Map();
|
|
29
|
+
pools = new Map();
|
|
30
|
+
pendingMessages = new Map();
|
|
31
|
+
locked = new Set();
|
|
32
|
+
/**
|
|
33
|
+
* Register a message type with priority and legitimacy requirements.
|
|
34
|
+
* Registration is immutable after first message of that type is sent.
|
|
35
|
+
*/
|
|
36
|
+
registerMessageType(name, definition) {
|
|
37
|
+
if (this.locked.has(name)) {
|
|
38
|
+
throw new Error(`[MessageTypeRegistry] Type '${name}' is locked (messages already sent)`);
|
|
39
|
+
}
|
|
40
|
+
this.types.set(name, { ...definition });
|
|
41
|
+
this.stats.set(name, {
|
|
42
|
+
sent: 0,
|
|
43
|
+
delivered: 0,
|
|
44
|
+
read: 0,
|
|
45
|
+
replied: 0,
|
|
46
|
+
blocked: 0,
|
|
47
|
+
avgActionDeltaMs: 0,
|
|
48
|
+
engagementScore: 100, // Start optimistic
|
|
49
|
+
});
|
|
50
|
+
// Create pool rate limiter if specified
|
|
51
|
+
if (definition.rateLimitPool && !this.pools.has(definition.rateLimitPool)) {
|
|
52
|
+
const poolConfig = this.getPoolConfig(definition.priority);
|
|
53
|
+
this.pools.set(definition.rateLimitPool, {
|
|
54
|
+
limiter: new RateLimiter(poolConfig),
|
|
55
|
+
config: poolConfig,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Send a message through the registry.
|
|
61
|
+
* Validates provenance and enforces rate limiting based on priority pool.
|
|
62
|
+
* Returns delay in ms before message can be sent.
|
|
63
|
+
*/
|
|
64
|
+
async send(sock, jid, content, options) {
|
|
65
|
+
const { type, provenance, engagementScore } = options;
|
|
66
|
+
// Validate type is registered
|
|
67
|
+
const definition = this.types.get(type);
|
|
68
|
+
if (!definition) {
|
|
69
|
+
throw new Error(`[MessageTypeRegistry] Type '${type}' not registered`);
|
|
70
|
+
}
|
|
71
|
+
// Lock type after first send
|
|
72
|
+
this.locked.add(type);
|
|
73
|
+
// Validate provenance for critical messages
|
|
74
|
+
if (definition.requiresProvenance && definition.requiresProvenance.length > 0) {
|
|
75
|
+
if (!provenance) {
|
|
76
|
+
throw new Error(`[MessageTypeRegistry] Type '${type}' requires provenance: ${definition.requiresProvenance.join(', ')}`);
|
|
77
|
+
}
|
|
78
|
+
// Check required fields
|
|
79
|
+
for (const field of definition.requiresProvenance) {
|
|
80
|
+
if (!(field in provenance)) {
|
|
81
|
+
throw new Error(`[MessageTypeRegistry] Type '${type}' requires provenance.${field}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// Validate legitimacy signals
|
|
86
|
+
if (definition.legitimacySignals) {
|
|
87
|
+
const signals = definition.legitimacySignals;
|
|
88
|
+
// Check action delta for critical messages
|
|
89
|
+
if (signals.maxActionDeltaMs !== undefined && provenance?.action_timestamp) {
|
|
90
|
+
const delta = Date.now() - provenance.action_timestamp;
|
|
91
|
+
if (delta > signals.maxActionDeltaMs) {
|
|
92
|
+
throw new Error(`[MessageTypeRegistry] Type '${type}' maxActionDeltaMs exceeded: ${delta}ms > ${signals.maxActionDeltaMs}ms`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// Check engagement score
|
|
96
|
+
if (signals.minEngagementScore !== undefined && engagementScore !== undefined) {
|
|
97
|
+
if (engagementScore < signals.minEngagementScore) {
|
|
98
|
+
throw new Error(`[MessageTypeRegistry] Type '${type}' minEngagementScore not met: ${engagementScore} < ${signals.minEngagementScore}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
// Check subscription age
|
|
102
|
+
if (signals.minSubscriptionAgeDays !== undefined && provenance?.subscription_verified_at) {
|
|
103
|
+
const ageDays = (Date.now() - provenance.subscription_verified_at) / (24 * 60 * 60 * 1000);
|
|
104
|
+
if (ageDays < signals.minSubscriptionAgeDays) {
|
|
105
|
+
throw new Error(`[MessageTypeRegistry] Type '${type}' minSubscriptionAgeDays not met: ${ageDays.toFixed(1)} < ${signals.minSubscriptionAgeDays}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
// Apply rate limiting based on pool
|
|
110
|
+
if (definition.rateLimitPool) {
|
|
111
|
+
const pool = this.pools.get(definition.rateLimitPool);
|
|
112
|
+
if (pool) {
|
|
113
|
+
const text = content?.text || content?.caption || '';
|
|
114
|
+
const delay = await pool.limiter.getDelay(jid, text);
|
|
115
|
+
if (delay === -1) {
|
|
116
|
+
throw new Error(`[MessageTypeRegistry] Rate limit exceeded for pool '${definition.rateLimitPool}'`);
|
|
117
|
+
}
|
|
118
|
+
if (delay > 0) {
|
|
119
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
// Send message
|
|
124
|
+
const result = await sock.sendMessage(jid, content);
|
|
125
|
+
const msgId = result?.key?.id;
|
|
126
|
+
// Record send
|
|
127
|
+
const stat = this.stats.get(type);
|
|
128
|
+
stat.sent++;
|
|
129
|
+
// Track action delta
|
|
130
|
+
if (provenance?.action_timestamp) {
|
|
131
|
+
const delta = Date.now() - provenance.action_timestamp;
|
|
132
|
+
// Rolling average
|
|
133
|
+
stat.avgActionDeltaMs = Math.floor((stat.avgActionDeltaMs * (stat.sent - 1) + delta) / stat.sent);
|
|
134
|
+
}
|
|
135
|
+
// Track pending message for delivery/read/reply tracking
|
|
136
|
+
if (msgId) {
|
|
137
|
+
this.pendingMessages.set(msgId, {
|
|
138
|
+
type,
|
|
139
|
+
sentAt: Date.now(),
|
|
140
|
+
provenance,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
// Record in pool
|
|
144
|
+
if (definition.rateLimitPool) {
|
|
145
|
+
const pool = this.pools.get(definition.rateLimitPool);
|
|
146
|
+
if (pool) {
|
|
147
|
+
pool.limiter.record(jid, content?.text || '');
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return result;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Record message delivered (status 3 = DELIVERY_ACK)
|
|
154
|
+
*/
|
|
155
|
+
recordDelivered(messageId) {
|
|
156
|
+
const record = this.pendingMessages.get(messageId);
|
|
157
|
+
if (!record)
|
|
158
|
+
return;
|
|
159
|
+
const stat = this.stats.get(record.type);
|
|
160
|
+
if (stat) {
|
|
161
|
+
stat.delivered++;
|
|
162
|
+
this.updateEngagementScore(record.type);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Record message read (status 4 = READ)
|
|
167
|
+
*/
|
|
168
|
+
recordRead(messageId) {
|
|
169
|
+
const record = this.pendingMessages.get(messageId);
|
|
170
|
+
if (!record)
|
|
171
|
+
return;
|
|
172
|
+
const stat = this.stats.get(record.type);
|
|
173
|
+
if (stat) {
|
|
174
|
+
stat.read++;
|
|
175
|
+
this.updateEngagementScore(record.type);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Record reply received
|
|
180
|
+
*/
|
|
181
|
+
recordReplied(messageId) {
|
|
182
|
+
const record = this.pendingMessages.get(messageId);
|
|
183
|
+
if (!record)
|
|
184
|
+
return;
|
|
185
|
+
const stat = this.stats.get(record.type);
|
|
186
|
+
if (stat) {
|
|
187
|
+
stat.replied++;
|
|
188
|
+
this.updateEngagementScore(record.type);
|
|
189
|
+
}
|
|
190
|
+
// Clean up after reply
|
|
191
|
+
this.pendingMessages.delete(messageId);
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Record message blocked (send failed with block error)
|
|
195
|
+
*/
|
|
196
|
+
recordBlocked(_jid) {
|
|
197
|
+
// Find recent messages and mark as blocked
|
|
198
|
+
for (const [_msgId, record] of this.pendingMessages.entries()) {
|
|
199
|
+
// Only count recent blocks (last 5 minutes)
|
|
200
|
+
if (Date.now() - record.sentAt < 5 * 60 * 1000) {
|
|
201
|
+
const stat = this.stats.get(record.type);
|
|
202
|
+
if (stat) {
|
|
203
|
+
stat.blocked++;
|
|
204
|
+
this.updateEngagementScore(record.type);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Get stats for a message type
|
|
211
|
+
*/
|
|
212
|
+
getStats(type) {
|
|
213
|
+
const stat = this.stats.get(type);
|
|
214
|
+
return stat ? { ...stat } : null;
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Get warnings for all message types.
|
|
218
|
+
* Returns array of warnings where metrics are below thresholds.
|
|
219
|
+
* NEVER auto-throttles — warnings only.
|
|
220
|
+
*/
|
|
221
|
+
getWarnings() {
|
|
222
|
+
const warnings = [];
|
|
223
|
+
const now = Date.now();
|
|
224
|
+
for (const [type, stat] of this.stats.entries()) {
|
|
225
|
+
// Skip types with insufficient data
|
|
226
|
+
if (stat.sent < 10)
|
|
227
|
+
continue;
|
|
228
|
+
// Engagement score warning (threshold: 50)
|
|
229
|
+
if (stat.engagementScore < 50) {
|
|
230
|
+
warnings.push({
|
|
231
|
+
type,
|
|
232
|
+
metric: 'engagement',
|
|
233
|
+
current: stat.engagementScore,
|
|
234
|
+
threshold: 50,
|
|
235
|
+
message: `Low engagement score for '${type}': ${stat.engagementScore.toFixed(1)}/100`,
|
|
236
|
+
});
|
|
237
|
+
const s = this.stats.get(type);
|
|
238
|
+
s.lastWarningAt = now;
|
|
239
|
+
}
|
|
240
|
+
// Delivery rate warning (threshold: 70%)
|
|
241
|
+
const deliveryRate = stat.sent > 0 ? stat.delivered / stat.sent : 1;
|
|
242
|
+
if (deliveryRate < 0.7 && stat.sent >= 20) {
|
|
243
|
+
warnings.push({
|
|
244
|
+
type,
|
|
245
|
+
metric: 'delivery_rate',
|
|
246
|
+
current: deliveryRate,
|
|
247
|
+
threshold: 0.7,
|
|
248
|
+
message: `Low delivery rate for '${type}': ${(deliveryRate * 100).toFixed(1)}%`,
|
|
249
|
+
});
|
|
250
|
+
const s = this.stats.get(type);
|
|
251
|
+
s.lastWarningAt = now;
|
|
252
|
+
}
|
|
253
|
+
// Blocked rate warning (threshold: 10%)
|
|
254
|
+
const blockedRate = stat.sent > 0 ? stat.blocked / stat.sent : 0;
|
|
255
|
+
if (blockedRate > 0.1 && stat.sent >= 20) {
|
|
256
|
+
warnings.push({
|
|
257
|
+
type,
|
|
258
|
+
metric: 'blocked_rate',
|
|
259
|
+
current: blockedRate,
|
|
260
|
+
threshold: 0.1,
|
|
261
|
+
message: `High blocked rate for '${type}': ${(blockedRate * 100).toFixed(1)}%`,
|
|
262
|
+
});
|
|
263
|
+
const s = this.stats.get(type);
|
|
264
|
+
s.lastWarningAt = now;
|
|
265
|
+
}
|
|
266
|
+
// Action delta warning for critical messages (threshold: 5 seconds)
|
|
267
|
+
const definition = this.types.get(type);
|
|
268
|
+
if (definition?.priority === 'critical' && stat.avgActionDeltaMs > 5000 && stat.sent >= 10) {
|
|
269
|
+
warnings.push({
|
|
270
|
+
type,
|
|
271
|
+
metric: 'action_delta',
|
|
272
|
+
current: stat.avgActionDeltaMs,
|
|
273
|
+
threshold: 5000,
|
|
274
|
+
message: `High action delta for '${type}': ${(stat.avgActionDeltaMs / 1000).toFixed(1)}s`,
|
|
275
|
+
});
|
|
276
|
+
const s = this.stats.get(type);
|
|
277
|
+
s.lastWarningAt = now;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return warnings;
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Export state for persistence
|
|
284
|
+
*/
|
|
285
|
+
exportState() {
|
|
286
|
+
const poolsState = {};
|
|
287
|
+
for (const [name, _pool] of this.pools.entries()) {
|
|
288
|
+
poolsState[name] = {
|
|
289
|
+
sent: [],
|
|
290
|
+
timestamps: [],
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
return {
|
|
294
|
+
types: Object.fromEntries(this.types.entries()),
|
|
295
|
+
stats: Object.fromEntries(this.stats.entries()),
|
|
296
|
+
pools: poolsState,
|
|
297
|
+
pendingMessages: Object.fromEntries(this.pendingMessages.entries()),
|
|
298
|
+
locked: Array.from(this.locked), // Will be converted to Set on import
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Import state from persistence
|
|
303
|
+
*/
|
|
304
|
+
importState(state) {
|
|
305
|
+
// Restore types
|
|
306
|
+
this.types = new Map(Object.entries(state.types));
|
|
307
|
+
// Restore stats
|
|
308
|
+
this.stats = new Map(Object.entries(state.stats));
|
|
309
|
+
// Restore pools (recreate rate limiters)
|
|
310
|
+
for (const [_name, definition] of this.types.entries()) {
|
|
311
|
+
if (definition.rateLimitPool && !this.pools.has(definition.rateLimitPool)) {
|
|
312
|
+
const poolConfig = this.getPoolConfig(definition.priority);
|
|
313
|
+
this.pools.set(definition.rateLimitPool, {
|
|
314
|
+
limiter: new RateLimiter(poolConfig),
|
|
315
|
+
config: poolConfig,
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
// Restore pending messages
|
|
320
|
+
this.pendingMessages = new Map(Object.entries(state.pendingMessages));
|
|
321
|
+
// Restore locked types
|
|
322
|
+
this.locked = new Set(Array.isArray(state.locked) ? state.locked : []);
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Clean up old pending messages (call periodically)
|
|
326
|
+
*/
|
|
327
|
+
cleanup() {
|
|
328
|
+
const now = Date.now();
|
|
329
|
+
const maxAge = 24 * 60 * 60 * 1000; // 24 hours
|
|
330
|
+
for (const [msgId, record] of this.pendingMessages.entries()) {
|
|
331
|
+
if (now - record.sentAt > maxAge) {
|
|
332
|
+
this.pendingMessages.delete(msgId);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
updateEngagementScore(type) {
|
|
337
|
+
const stat = this.stats.get(type);
|
|
338
|
+
if (!stat || stat.sent === 0)
|
|
339
|
+
return;
|
|
340
|
+
// Engagement score = weighted: (read*0.3 + replied*0.5 + (1-blocked)*0.2) × 100
|
|
341
|
+
// Rolling 7-day window (use all stats for simplicity)
|
|
342
|
+
const readRate = stat.read / stat.sent;
|
|
343
|
+
const replyRate = stat.replied / stat.sent;
|
|
344
|
+
const notBlockedRate = 1 - (stat.blocked / stat.sent);
|
|
345
|
+
stat.engagementScore = Math.round((readRate * 0.3 + replyRate * 0.5 + notBlockedRate * 0.2) * 100);
|
|
346
|
+
}
|
|
347
|
+
getPoolConfig(priority) {
|
|
348
|
+
switch (priority) {
|
|
349
|
+
case 'critical':
|
|
350
|
+
return {
|
|
351
|
+
...DEFAULT_POOL_CONFIG,
|
|
352
|
+
maxPerMinute: 5,
|
|
353
|
+
maxPerHour: 100,
|
|
354
|
+
maxPerDay: 500,
|
|
355
|
+
};
|
|
356
|
+
case 'bulk':
|
|
357
|
+
return {
|
|
358
|
+
...DEFAULT_POOL_CONFIG,
|
|
359
|
+
maxPerMinute: 15,
|
|
360
|
+
maxPerHour: 300,
|
|
361
|
+
maxPerDay: 2000,
|
|
362
|
+
minDelayMs: 1000,
|
|
363
|
+
maxDelayMs: 3000,
|
|
364
|
+
};
|
|
365
|
+
default:
|
|
366
|
+
return DEFAULT_POOL_CONFIG;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|