@prototypevip/baileys 0.0.8 → 0.0.9
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/cli.js +177 -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/groups.js +26 -21
- package/lib/Socket/index.js +139 -7
- package/lib/Socket/messages-send.js +68 -5
- package/lib/Utils/ai-rich-formatter.js +256 -0
- package/lib/Utils/index.js +1 -0
- package/lib/index.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* baileys-antiban — Anti-ban middleware for Baileys
|
|
3
|
+
*
|
|
4
|
+
* Wraps a Baileys socket with human-like messaging patterns
|
|
5
|
+
* to minimize the risk of WhatsApp banning your number.
|
|
6
|
+
*
|
|
7
|
+
* @author Kobus Wentzel <kobie@pop.co.za>
|
|
8
|
+
* @license MIT
|
|
9
|
+
*/
|
|
10
|
+
// Core
|
|
11
|
+
export { AntiBan } from './antiban.js';
|
|
12
|
+
export { RateLimiter } from './rateLimiter.js';
|
|
13
|
+
export { WarmUp } from './warmup.js';
|
|
14
|
+
export { HealthMonitor } from './health.js';
|
|
15
|
+
export { TimelockGuard } from './timelockGuard.js';
|
|
16
|
+
// v1.3 new modules
|
|
17
|
+
export { ReplyRatioGuard } from './replyRatio.js';
|
|
18
|
+
export { ContactGraphWarmer } from './contactGraph.js';
|
|
19
|
+
export { PresenceChoreographer, getCircadianMultiplier, } from './presenceChoreographer.js';
|
|
20
|
+
// v1.5 new modules
|
|
21
|
+
export { RetryReasonTracker } from './retryTracker.js';
|
|
22
|
+
export { PostReconnectThrottle } from './reconnectThrottle.js';
|
|
23
|
+
// v1.6 new modules
|
|
24
|
+
export { LidResolver } from './lidResolver.js';
|
|
25
|
+
export { JidCanonicalizer } from './jidCanonicalizer.js';
|
|
26
|
+
// v2.0 new modules
|
|
27
|
+
export { SessionHealthMonitor, wrapWithSessionStability, classifyDisconnect, DeafSessionDetector, } from './sessionStability.js';
|
|
28
|
+
// v2.1 new modules
|
|
29
|
+
export { LidFirstResolver, createLidFirstResolver, } from './lidFirstResolver.js';
|
|
30
|
+
export { MessageRetryReason, MAC_ERROR_CODES, parseRetryReason, isMacError, getRetryReasonDescription, } from './retryReason.js';
|
|
31
|
+
// Socket wrapper
|
|
32
|
+
export { wrapSocket, wrapSocketWithFingerprint } from './wrapper.js';
|
|
33
|
+
// Optional features
|
|
34
|
+
export { MessageQueue } from './messageQueue.js';
|
|
35
|
+
export { ContentVariator } from './contentVariator.js';
|
|
36
|
+
export { WebhookAlerts } from './webhooks.js';
|
|
37
|
+
export { Scheduler } from './scheduler.js';
|
|
38
|
+
// State persistence
|
|
39
|
+
export { FileStateAdapter } from './stateAdapter.js';
|
|
40
|
+
// v3.0 new modules
|
|
41
|
+
export { resolveConfig, PRESETS } from './presets.js';
|
|
42
|
+
export { StateManager } from './persist.js';
|
|
43
|
+
export { isGroup, isNewsletter, isBroadcast, shouldUseGroupProfile, applyGroupMultiplier } from './profiles.js';
|
|
44
|
+
// v3.1 new modules
|
|
45
|
+
export { messageRecovery } from './messageRecovery.js';
|
|
46
|
+
// v3.2 new modules
|
|
47
|
+
export { generateFingerprint, applyFingerprint, } from './deviceFingerprint.js';
|
|
48
|
+
export { credsSnapshot, } from './credsSnapshot.js';
|
|
49
|
+
export { readReceiptVariance, } from './readReceiptVariance.js';
|
|
50
|
+
// v3.5 new modules
|
|
51
|
+
export { proxyRotator, } from './proxyRotator.js';
|
|
52
|
+
// v3.6 new modules (Obscura-inspired)
|
|
53
|
+
export { generateSessionFingerprint, applySessionFingerprint, getMessageSendJitter, getTypingJitter, getRetryJitter, getVoiceNoteMetadata, getBatteryState, createStealthFingerprint, } from './sessionFingerprint.js';
|
|
54
|
+
// v3.8 new modules
|
|
55
|
+
export { getStealthSocketConfig, rampPresenceAfterConnect, STEALTH_BROWSER_POOL, AbortError, } from './stealthConnect.js';
|
|
56
|
+
// v4.0 new modules
|
|
57
|
+
export { GroupOperationGuard, classifyGroupOpError, extractPrivacyBlock, GROUP_OP_ERRORS, } from './groupOperationGuard.js';
|
|
58
|
+
// v4.1 new modules
|
|
59
|
+
export { LegitimacySignalInjector, } from './legitimacySignalInjector.js';
|
|
60
|
+
// v4.2 new modules
|
|
61
|
+
export { BanRecoveryOrchestrator, } from './banRecoveryOrchestrator.js';
|
|
62
|
+
// v4.3 new modules
|
|
63
|
+
export { DeliveryTracker } from './deliveryTracker.js';
|
|
64
|
+
// v4.4 new modules
|
|
65
|
+
export { InstanceCoordinator } from './instanceCoordinator.js';
|
|
66
|
+
// v4.7 new modules
|
|
67
|
+
export { JidCircuitBreaker, createJidCircuitBreaker } from './jidCircuitBreaker.js';
|
|
68
|
+
export { createFleetEventStore, createMySQLEventStoreBackend, createInMemoryEventStoreBackend } from './fleetEventStore.js';
|
|
69
|
+
export { HumanEntropyService, createHumanEntropyService } from './humanEntropy.js';
|
|
70
|
+
// v4.8 new modules
|
|
71
|
+
export { MessageTypeRegistry } from './messageTypeRegistry.js';
|
|
72
|
+
export { exportAntibanState, importAntibanState } from './stateExport.js';
|
|
73
|
+
export { TopologyThrottler } from './topologyThrottler.js';
|
|
74
|
+
// v4.9 new modules
|
|
75
|
+
export { ReputationVoucher } from './reputationVoucher.js';
|
|
76
|
+
// Observability
|
|
77
|
+
export { createConsoleLogger, exportPrometheusMetrics, createMetricsHandler, createPeriodicExporter, } from './observability.js';
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* InstanceCoordinator — cross-process rate pool via shared state file
|
|
3
|
+
*
|
|
4
|
+
* Problem: N bot instances on the same IP each enforce their own per-minute
|
|
5
|
+
* limit. Collectively they can exceed safe IP-level thresholds.
|
|
6
|
+
*
|
|
7
|
+
* Solution: shared JSON file updated on every send. Each instance reads the
|
|
8
|
+
* shared pool before sending and deducts from a shared per-minute budget.
|
|
9
|
+
*
|
|
10
|
+
* File format: { sends: number[], updatedAt: number }
|
|
11
|
+
* sends = array of timestamps (ms) of recent sends across ALL instances
|
|
12
|
+
*
|
|
13
|
+
* Coordination model: optimistic read-modify-write with rename-swap atomicity.
|
|
14
|
+
* Race window is tiny (sub-ms) and consequences are minor (brief over-limit).
|
|
15
|
+
* No hard lock needed — this is best-effort coordination, not a mutex.
|
|
16
|
+
*/
|
|
17
|
+
import * as fs from 'fs';
|
|
18
|
+
import * as path from 'path';
|
|
19
|
+
export class InstanceCoordinator {
|
|
20
|
+
config;
|
|
21
|
+
constructor(config) {
|
|
22
|
+
this.config = {
|
|
23
|
+
sharedFilePath: config.sharedFilePath,
|
|
24
|
+
poolMaxPerMinute: config.poolMaxPerMinute ?? 20,
|
|
25
|
+
poolMaxPerHour: config.poolMaxPerHour ?? 500,
|
|
26
|
+
poolExhaustedDelayMs: config.poolExhaustedDelayMs ?? 5000,
|
|
27
|
+
staleThresholdMs: config.staleThresholdMs ?? 120000, // 2 minutes
|
|
28
|
+
};
|
|
29
|
+
// Ensure directory exists
|
|
30
|
+
const dir = path.dirname(this.config.sharedFilePath);
|
|
31
|
+
if (!fs.existsSync(dir)) {
|
|
32
|
+
try {
|
|
33
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
34
|
+
}
|
|
35
|
+
catch (err) {
|
|
36
|
+
console.warn(`[baileys-antiban] instanceCoordinator: Failed to create directory ${dir}:`, err);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Try to acquire a send slot from the shared pool.
|
|
42
|
+
* Returns { allowed: true } if send is allowed, or { allowed: false, retryAfterMs } if pool is exhausted.
|
|
43
|
+
*/
|
|
44
|
+
tryAcquireSlot() {
|
|
45
|
+
try {
|
|
46
|
+
const now = Date.now();
|
|
47
|
+
let state = this.readState();
|
|
48
|
+
// Filter stale timestamps (older than staleThreshold)
|
|
49
|
+
const staleThreshold = now - this.config.staleThresholdMs;
|
|
50
|
+
state.sends = state.sends.filter(ts => ts > staleThreshold);
|
|
51
|
+
// Calculate time windows
|
|
52
|
+
const oneMinuteAgo = now - 60000;
|
|
53
|
+
const oneHourAgo = now - 3600000;
|
|
54
|
+
const sendsLastMinute = state.sends.filter(ts => ts > oneMinuteAgo).length;
|
|
55
|
+
const sendsLastHour = state.sends.filter(ts => ts > oneHourAgo).length;
|
|
56
|
+
// Check minute limit
|
|
57
|
+
if (sendsLastMinute >= this.config.poolMaxPerMinute) {
|
|
58
|
+
const oldestInWindow = state.sends.filter(ts => ts > oneMinuteAgo).sort((a, b) => a - b)[0];
|
|
59
|
+
const retryAfterMs = oldestInWindow ? Math.max(1000, oldestInWindow + 60000 - now) : this.config.poolExhaustedDelayMs;
|
|
60
|
+
return { allowed: false, retryAfterMs };
|
|
61
|
+
}
|
|
62
|
+
// Check hour limit
|
|
63
|
+
if (sendsLastHour >= this.config.poolMaxPerHour) {
|
|
64
|
+
const oldestInWindow = state.sends.filter(ts => ts > oneHourAgo).sort((a, b) => a - b)[0];
|
|
65
|
+
const retryAfterMs = oldestInWindow ? Math.max(10000, oldestInWindow + 3600000 - now) : 60000;
|
|
66
|
+
return { allowed: false, retryAfterMs };
|
|
67
|
+
}
|
|
68
|
+
// Acquire slot — add current timestamp
|
|
69
|
+
state.sends.push(now);
|
|
70
|
+
state.updatedAt = now;
|
|
71
|
+
// Prune to last 2 hours to prevent unbounded growth
|
|
72
|
+
const twoHoursAgo = now - 7200000;
|
|
73
|
+
state.sends = state.sends.filter(ts => ts > twoHoursAgo);
|
|
74
|
+
// Write back atomically
|
|
75
|
+
this.writeState(state);
|
|
76
|
+
return { allowed: true };
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
// Fail open — coordination failure should never block sends
|
|
80
|
+
console.warn('[baileys-antiban] instanceCoordinator: Error in tryAcquireSlot, failing open:', err);
|
|
81
|
+
return { allowed: true };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Get current statistics for the shared pool
|
|
86
|
+
*/
|
|
87
|
+
getStats() {
|
|
88
|
+
try {
|
|
89
|
+
const now = Date.now();
|
|
90
|
+
const state = this.readState();
|
|
91
|
+
const oneMinuteAgo = now - 60000;
|
|
92
|
+
const oneHourAgo = now - 3600000;
|
|
93
|
+
const sendsLastMinute = state.sends.filter(ts => ts > oneMinuteAgo).length;
|
|
94
|
+
const sendsLastHour = state.sends.filter(ts => ts > oneHourAgo).length;
|
|
95
|
+
return {
|
|
96
|
+
poolSendsLastMinute: sendsLastMinute,
|
|
97
|
+
poolSendsLastHour: sendsLastHour,
|
|
98
|
+
poolMaxPerMinute: this.config.poolMaxPerMinute,
|
|
99
|
+
poolMaxPerHour: this.config.poolMaxPerHour,
|
|
100
|
+
poolUtilization: sendsLastMinute / this.config.poolMaxPerMinute,
|
|
101
|
+
coordinationFilePath: this.config.sharedFilePath,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
console.warn('[baileys-antiban] instanceCoordinator: Error in getStats:', err);
|
|
106
|
+
return {
|
|
107
|
+
poolSendsLastMinute: 0,
|
|
108
|
+
poolSendsLastHour: 0,
|
|
109
|
+
poolMaxPerMinute: this.config.poolMaxPerMinute,
|
|
110
|
+
poolMaxPerHour: this.config.poolMaxPerHour,
|
|
111
|
+
poolUtilization: 0,
|
|
112
|
+
coordinationFilePath: this.config.sharedFilePath,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Read coordination state from file. Returns empty state if file doesn't exist.
|
|
118
|
+
*/
|
|
119
|
+
readState() {
|
|
120
|
+
try {
|
|
121
|
+
if (!fs.existsSync(this.config.sharedFilePath)) {
|
|
122
|
+
return { sends: [], updatedAt: Date.now() };
|
|
123
|
+
}
|
|
124
|
+
const content = fs.readFileSync(this.config.sharedFilePath, 'utf-8');
|
|
125
|
+
const parsed = JSON.parse(content);
|
|
126
|
+
// Validate structure
|
|
127
|
+
if (!Array.isArray(parsed.sends) || typeof parsed.updatedAt !== 'number') {
|
|
128
|
+
console.warn('[baileys-antiban] instanceCoordinator: Invalid state file format, resetting');
|
|
129
|
+
return { sends: [], updatedAt: Date.now() };
|
|
130
|
+
}
|
|
131
|
+
return parsed;
|
|
132
|
+
}
|
|
133
|
+
catch (err) {
|
|
134
|
+
// File doesn't exist or is corrupt — return empty state
|
|
135
|
+
return { sends: [], updatedAt: Date.now() };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Write coordination state to file atomically using rename-swap
|
|
140
|
+
*/
|
|
141
|
+
writeState(state) {
|
|
142
|
+
try {
|
|
143
|
+
const tmp = `${this.config.sharedFilePath}.tmp.${process.pid}`;
|
|
144
|
+
fs.writeFileSync(tmp, JSON.stringify(state), 'utf-8');
|
|
145
|
+
fs.renameSync(tmp, this.config.sharedFilePath);
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
console.warn('[baileys-antiban] instanceCoordinator: Failed to write state file:', err);
|
|
149
|
+
// Fail silently — don't throw, this is best-effort coordination
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* BUG FIX 3: Sync local rate limiter with shared pool after reconnect
|
|
154
|
+
* Reads shared pool timestamps and injects them into the local limiter's sliding window.
|
|
155
|
+
* This prevents the double-spend window where the limiter thinks it has full budget
|
|
156
|
+
* post-reconnect but the shared pool already shows most slots used.
|
|
157
|
+
*/
|
|
158
|
+
syncLocalLimiter(rateLimiter) {
|
|
159
|
+
try {
|
|
160
|
+
const state = this.readState();
|
|
161
|
+
const now = Date.now();
|
|
162
|
+
// Filter to last 24 hours (rate limiter's cleanup window)
|
|
163
|
+
const recentTimestamps = state.sends.filter(ts => now - ts < 86400000);
|
|
164
|
+
// Inject into local rate limiter
|
|
165
|
+
rateLimiter.injectTimestamps(recentTimestamps);
|
|
166
|
+
if (recentTimestamps.length > 0) {
|
|
167
|
+
console.log(`[baileys-antiban] instanceCoordinator: Synced ${recentTimestamps.length} shared pool timestamps to local limiter after reconnect`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
catch (err) {
|
|
171
|
+
console.warn('[baileys-antiban] instanceCoordinator: Failed to sync local limiter:', err);
|
|
172
|
+
// Fail silently — this is best-effort sync
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JID Canonicalizer — Opt-in middleware for LID/PN normalization
|
|
3
|
+
*
|
|
4
|
+
* Wraps LidResolver to provide automatic:
|
|
5
|
+
* 1. Learning from incoming message events
|
|
6
|
+
* 2. Canonicalization of outbound send targets
|
|
7
|
+
*
|
|
8
|
+
* This mitigates the LID/PN race condition that causes "Bad MAC / No Session /
|
|
9
|
+
* Invalid PreKey" errors (Baileys issue #1769, our PR #2372).
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* const canonicalizer = new JidCanonicalizer({ enabled: true });
|
|
13
|
+
*
|
|
14
|
+
* // On incoming event
|
|
15
|
+
* canonicalizer.onIncomingEvent({ messages: [...] });
|
|
16
|
+
*
|
|
17
|
+
* // On outbound send
|
|
18
|
+
* const canonicalJid = canonicalizer.canonicalizeTarget(jid);
|
|
19
|
+
* await sock.sendMessage(canonicalJid, content);
|
|
20
|
+
*
|
|
21
|
+
* Note: This is a middleware-layer mitigation. The root fix requires merging
|
|
22
|
+
* PR #2372 into Baileys' crypto pipeline.
|
|
23
|
+
*/
|
|
24
|
+
import { LidResolver } from './lidResolver.js';
|
|
25
|
+
const DEFAULT_CONFIG = {
|
|
26
|
+
enabled: false,
|
|
27
|
+
canonicalizeOutbound: true,
|
|
28
|
+
learnFromEvents: true,
|
|
29
|
+
};
|
|
30
|
+
export class JidCanonicalizer {
|
|
31
|
+
config;
|
|
32
|
+
lidResolver;
|
|
33
|
+
ownsResolver; // Track if we created the resolver (for destroy)
|
|
34
|
+
stats = {
|
|
35
|
+
outboundCanonicalized: 0,
|
|
36
|
+
outboundPassthrough: 0,
|
|
37
|
+
inboundLearned: 0,
|
|
38
|
+
canonicalKeyHits: 0,
|
|
39
|
+
canonicalKeyMisses: 0,
|
|
40
|
+
};
|
|
41
|
+
constructor(config = {}) {
|
|
42
|
+
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
43
|
+
// Use provided resolver or create new one
|
|
44
|
+
if (config.resolver) {
|
|
45
|
+
this.lidResolver = config.resolver;
|
|
46
|
+
this.ownsResolver = false;
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
this.lidResolver = new LidResolver(config.resolverConfig);
|
|
50
|
+
this.ownsResolver = true;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Access the underlying resolver (for cross-module sharing)
|
|
55
|
+
*/
|
|
56
|
+
get resolver() {
|
|
57
|
+
return this.lidResolver;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Called by wrapper on every outbound send. Returns canonical JID.
|
|
61
|
+
*/
|
|
62
|
+
canonicalizeTarget(jid) {
|
|
63
|
+
if (!this.config.enabled || !this.config.canonicalizeOutbound) {
|
|
64
|
+
return jid;
|
|
65
|
+
}
|
|
66
|
+
const canonical = this.lidResolver.resolveCanonical(jid);
|
|
67
|
+
if (canonical !== jid) {
|
|
68
|
+
this.stats.outboundCanonicalized++;
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
this.stats.outboundPassthrough++;
|
|
72
|
+
}
|
|
73
|
+
return canonical;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Returns a stable, canonical thread key for storage / DB indexing.
|
|
77
|
+
*
|
|
78
|
+
* Different from `canonicalizeTarget()` (which picks the right send target):
|
|
79
|
+
* - canonicalizeTarget('1234@lid') → '+27...@s.whatsapp.net' (best send target)
|
|
80
|
+
* - canonicalKey('1234@lid') → 'thread:27...' (stable thread identifier)
|
|
81
|
+
*
|
|
82
|
+
* If LID has known PN mapping → use phone-number form
|
|
83
|
+
* If only LID known → use LID stripped of suffix
|
|
84
|
+
* Always lowercase, no @-suffix, prefixed with `thread:`
|
|
85
|
+
*
|
|
86
|
+
* Apps using this as their DB key won't double-thread on LID/PN drift.
|
|
87
|
+
*
|
|
88
|
+
* @param jid - WhatsApp JID (can be PN, LID, group, or broadcast)
|
|
89
|
+
* @returns Stable thread key for DB indexing
|
|
90
|
+
*/
|
|
91
|
+
canonicalKey(jid) {
|
|
92
|
+
// Defensive: handle null/undefined/empty
|
|
93
|
+
if (!jid || typeof jid !== 'string' || jid.trim() === '') {
|
|
94
|
+
return 'thread:invalid';
|
|
95
|
+
}
|
|
96
|
+
const normalized = jid.trim().toLowerCase();
|
|
97
|
+
// Extract parts: user@domain
|
|
98
|
+
const atIndex = normalized.indexOf('@');
|
|
99
|
+
if (atIndex === -1) {
|
|
100
|
+
return 'thread:invalid';
|
|
101
|
+
}
|
|
102
|
+
const user = normalized.substring(0, atIndex);
|
|
103
|
+
const domain = normalized.substring(atIndex + 1);
|
|
104
|
+
// Handle special domains
|
|
105
|
+
if (domain === 'g.us') {
|
|
106
|
+
// Group chat
|
|
107
|
+
return `thread:group:${user}`;
|
|
108
|
+
}
|
|
109
|
+
if (domain === 'broadcast') {
|
|
110
|
+
// Broadcast list
|
|
111
|
+
return `thread:broadcast:${user}`;
|
|
112
|
+
}
|
|
113
|
+
if (domain === 'newsletter') {
|
|
114
|
+
// Newsletter (WA Channels)
|
|
115
|
+
return `thread:newsletter:${user}`;
|
|
116
|
+
}
|
|
117
|
+
// Handle @s.whatsapp.net (PN form)
|
|
118
|
+
if (domain === 's.whatsapp.net') {
|
|
119
|
+
this.stats.canonicalKeyHits++;
|
|
120
|
+
return `thread:${user}`;
|
|
121
|
+
}
|
|
122
|
+
// Handle @lid form
|
|
123
|
+
if (domain === 'lid') {
|
|
124
|
+
// Try to resolve to PN via learned mappings
|
|
125
|
+
const mapping = this.lidResolver.getMapping(normalized);
|
|
126
|
+
if (mapping?.pn) {
|
|
127
|
+
// We have a PN mapping — use it
|
|
128
|
+
const pnUser = mapping.pn.split('@')[0];
|
|
129
|
+
this.stats.canonicalKeyHits++;
|
|
130
|
+
return `thread:${pnUser}`;
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
// No PN known yet — use LID form
|
|
134
|
+
this.stats.canonicalKeyMisses++;
|
|
135
|
+
return `thread:lid:${user}`;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
// Unknown domain — return generic form
|
|
139
|
+
return `thread:${domain}:${user}`;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Called by wrapper on messages.upsert event. Learns mappings.
|
|
143
|
+
*/
|
|
144
|
+
onIncomingEvent(upsert) {
|
|
145
|
+
if (!this.config.enabled || !this.config.learnFromEvents) {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
for (const msg of upsert.messages || []) {
|
|
149
|
+
this.learnFromMessage(msg);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Learn LID↔PN mappings from group metadata participants.
|
|
154
|
+
* Call after fetchGroupMetadata() to pre-populate the resolver map.
|
|
155
|
+
* No-op if canonicalization is disabled.
|
|
156
|
+
*
|
|
157
|
+
* @param participants - Group metadata participants array from Baileys
|
|
158
|
+
* @returns Number of new mappings learned (0 if disabled)
|
|
159
|
+
*/
|
|
160
|
+
learnFromGroupMetadata(participants) {
|
|
161
|
+
if (!this.config.enabled || !this.config.learnFromEvents) {
|
|
162
|
+
return 0;
|
|
163
|
+
}
|
|
164
|
+
return this.lidResolver.learnFromGroupMetadata(participants);
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Called by wrapper on messages.update event. Learns from sent-message refs.
|
|
168
|
+
*/
|
|
169
|
+
onMessageUpdate(updates) {
|
|
170
|
+
if (!this.config.enabled || !this.config.learnFromEvents) {
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
for (const update of updates) {
|
|
174
|
+
// messages.update doesn't typically carry LID info — mostly for retry tracking
|
|
175
|
+
// But handle edge case where update.key has participant/participantPn
|
|
176
|
+
if (update.key) {
|
|
177
|
+
this.learnFromMessageKey(update.key);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
getStats() {
|
|
182
|
+
return {
|
|
183
|
+
resolver: this.lidResolver.getStats(),
|
|
184
|
+
outboundCanonicalized: this.stats.outboundCanonicalized,
|
|
185
|
+
outboundPassthrough: this.stats.outboundPassthrough,
|
|
186
|
+
inboundLearned: this.stats.inboundLearned,
|
|
187
|
+
canonicalKeyHits: this.stats.canonicalKeyHits,
|
|
188
|
+
canonicalKeyMisses: this.stats.canonicalKeyMisses,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
destroy() {
|
|
192
|
+
// Only destroy resolver if we created it
|
|
193
|
+
if (this.ownsResolver) {
|
|
194
|
+
this.lidResolver.destroy();
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
// Private helpers
|
|
198
|
+
/**
|
|
199
|
+
* Extract LID↔PN mappings from a message object
|
|
200
|
+
*/
|
|
201
|
+
learnFromMessage(msg) {
|
|
202
|
+
if (!msg.key)
|
|
203
|
+
return;
|
|
204
|
+
// Extract from message key
|
|
205
|
+
this.learnFromMessageKey(msg.key);
|
|
206
|
+
// Additional extraction from message envelope (if present)
|
|
207
|
+
// Some Baileys forks/versions expose participantPn at the message level
|
|
208
|
+
if (msg.participantPn && msg.key.participant) {
|
|
209
|
+
this.lidResolver.learn({
|
|
210
|
+
lid: msg.key.participant.endsWith('@lid') ? msg.key.participant : undefined,
|
|
211
|
+
pn: msg.participantPn,
|
|
212
|
+
});
|
|
213
|
+
this.stats.inboundLearned++;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Extract mappings from message.key
|
|
218
|
+
*/
|
|
219
|
+
learnFromMessageKey(key) {
|
|
220
|
+
if (!key)
|
|
221
|
+
return;
|
|
222
|
+
// Case 1: participant (LID) + participantPn (PN)
|
|
223
|
+
// This appears in group messages where sender uses LID
|
|
224
|
+
if (key.participant && key.participantPn) {
|
|
225
|
+
if (key.participant.endsWith('@lid')) {
|
|
226
|
+
this.lidResolver.learn({
|
|
227
|
+
lid: key.participant,
|
|
228
|
+
pn: key.participantPn,
|
|
229
|
+
});
|
|
230
|
+
this.stats.inboundLearned++;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
// Case 2: remoteJid (LID) + senderPn (PN)
|
|
234
|
+
// This appears in 1:1 messages from LID senders
|
|
235
|
+
if (key.remoteJid && key.senderPn) {
|
|
236
|
+
if (key.remoteJid.endsWith('@lid')) {
|
|
237
|
+
this.lidResolver.learn({
|
|
238
|
+
lid: key.remoteJid,
|
|
239
|
+
pn: key.senderPn,
|
|
240
|
+
});
|
|
241
|
+
this.stats.inboundLearned++;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
// Case 3: participant (PN) exists but we have remoteJid (LID)
|
|
245
|
+
// Inverse case where participant is PN form
|
|
246
|
+
if (key.participant && key.remoteJid) {
|
|
247
|
+
if (key.participant.endsWith('@s.whatsapp.net') && key.remoteJid.endsWith('@lid')) {
|
|
248
|
+
this.lidResolver.learn({
|
|
249
|
+
lid: key.remoteJid,
|
|
250
|
+
pn: key.participant,
|
|
251
|
+
});
|
|
252
|
+
this.stats.inboundLearned++;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JID Circuit Breaker — Per-recipient circuit breaker for send protection
|
|
3
|
+
*
|
|
4
|
+
* Tracks failures per JID and opens circuit after threshold to prevent
|
|
5
|
+
* cascading failures and reduce ban risk on problematic recipients.
|
|
6
|
+
*
|
|
7
|
+
* State machine:
|
|
8
|
+
* - closed: Normal operation, sends allowed
|
|
9
|
+
* - open: Threshold exceeded, sends blocked until cooldown
|
|
10
|
+
* - half-open: Cooldown elapsed, allow one probe send
|
|
11
|
+
*
|
|
12
|
+
* Usage:
|
|
13
|
+
* const breaker = createJidCircuitBreaker({ failureThreshold: 3, cooldownMs: 30_000 });
|
|
14
|
+
* if (!breaker.canSend(jid)) throw new Error('circuit open');
|
|
15
|
+
* // ... send message ...
|
|
16
|
+
* breaker.recordSuccess(jid); // or recordFailure(jid)
|
|
17
|
+
*/
|
|
18
|
+
const EVICTION_AGE_MS = 10 * 60 * 1000; // 10 minutes
|
|
19
|
+
export class JidCircuitBreaker {
|
|
20
|
+
failureThreshold;
|
|
21
|
+
cooldownMs;
|
|
22
|
+
logger;
|
|
23
|
+
circuits = new Map();
|
|
24
|
+
constructor(config) {
|
|
25
|
+
this.failureThreshold = config?.failureThreshold ?? 3;
|
|
26
|
+
this.cooldownMs = config?.cooldownMs ?? 30_000;
|
|
27
|
+
this.logger = config?.logger;
|
|
28
|
+
}
|
|
29
|
+
evictStale() {
|
|
30
|
+
const now = Date.now();
|
|
31
|
+
const staleJids = [];
|
|
32
|
+
this.circuits.forEach((entry, jid) => {
|
|
33
|
+
const age = entry.openedAt ? now - entry.openedAt : Infinity;
|
|
34
|
+
if (age > EVICTION_AGE_MS && entry.state === 'closed') {
|
|
35
|
+
staleJids.push(jid);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
for (const jid of staleJids) {
|
|
39
|
+
this.circuits.delete(jid);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
getOrCreateEntry(jid) {
|
|
43
|
+
let entry = this.circuits.get(jid);
|
|
44
|
+
if (!entry) {
|
|
45
|
+
entry = {
|
|
46
|
+
state: 'closed',
|
|
47
|
+
failures: 0,
|
|
48
|
+
openedAt: null,
|
|
49
|
+
halfOpenProbeUsed: false,
|
|
50
|
+
};
|
|
51
|
+
this.circuits.set(jid, entry);
|
|
52
|
+
// Evict stale entries periodically
|
|
53
|
+
if (this.circuits.size > 1000) {
|
|
54
|
+
this.evictStale();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return entry;
|
|
58
|
+
}
|
|
59
|
+
canSend(jid) {
|
|
60
|
+
const entry = this.getOrCreateEntry(jid);
|
|
61
|
+
const now = Date.now();
|
|
62
|
+
if (entry.state === 'closed') {
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
if (entry.state === 'open') {
|
|
66
|
+
// Check if cooldown has elapsed
|
|
67
|
+
if (entry.openedAt && now - entry.openedAt >= this.cooldownMs) {
|
|
68
|
+
// Transition to half-open
|
|
69
|
+
entry.state = 'half-open';
|
|
70
|
+
entry.halfOpenProbeUsed = false;
|
|
71
|
+
this.logger?.info('[circuit-breaker] transitioning to half-open', { jid });
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
if (entry.state === 'half-open') {
|
|
77
|
+
// Allow one probe
|
|
78
|
+
if (!entry.halfOpenProbeUsed) {
|
|
79
|
+
entry.halfOpenProbeUsed = true;
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
recordSuccess(jid) {
|
|
87
|
+
const entry = this.getOrCreateEntry(jid);
|
|
88
|
+
if (entry.state === 'half-open') {
|
|
89
|
+
// Probe succeeded, reset to closed
|
|
90
|
+
entry.state = 'closed';
|
|
91
|
+
entry.failures = 0;
|
|
92
|
+
entry.openedAt = null;
|
|
93
|
+
entry.halfOpenProbeUsed = false;
|
|
94
|
+
this.logger?.info('[circuit-breaker] reset to closed after successful probe', { jid });
|
|
95
|
+
}
|
|
96
|
+
else if (entry.state === 'closed') {
|
|
97
|
+
// Normal operation, reset failure count
|
|
98
|
+
entry.failures = 0;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
recordFailure(jid) {
|
|
102
|
+
const entry = this.getOrCreateEntry(jid);
|
|
103
|
+
const now = Date.now();
|
|
104
|
+
if (entry.state === 'half-open') {
|
|
105
|
+
// Probe failed, reopen circuit
|
|
106
|
+
entry.state = 'open';
|
|
107
|
+
entry.openedAt = now;
|
|
108
|
+
entry.halfOpenProbeUsed = false;
|
|
109
|
+
this.logger?.warn('[circuit-breaker] reopened after failed probe', { jid });
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (entry.state === 'closed') {
|
|
113
|
+
entry.failures += 1;
|
|
114
|
+
if (entry.failures >= this.failureThreshold) {
|
|
115
|
+
entry.state = 'open';
|
|
116
|
+
entry.openedAt = now;
|
|
117
|
+
this.logger?.warn('[circuit-breaker] opened circuit', { jid, failures: entry.failures, threshold: this.failureThreshold });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
getState(jid) {
|
|
122
|
+
const entry = this.circuits.get(jid);
|
|
123
|
+
return entry?.state ?? 'closed';
|
|
124
|
+
}
|
|
125
|
+
getJitter(isBroadcast) {
|
|
126
|
+
if (!isBroadcast)
|
|
127
|
+
return 0;
|
|
128
|
+
return Math.floor(Math.random() * 500) + 400;
|
|
129
|
+
}
|
|
130
|
+
getStats() {
|
|
131
|
+
let open = 0;
|
|
132
|
+
let halfOpen = 0;
|
|
133
|
+
let closed = 0;
|
|
134
|
+
this.circuits.forEach((entry) => {
|
|
135
|
+
if (entry.state === 'open')
|
|
136
|
+
open++;
|
|
137
|
+
else if (entry.state === 'half-open')
|
|
138
|
+
halfOpen++;
|
|
139
|
+
else
|
|
140
|
+
closed++;
|
|
141
|
+
});
|
|
142
|
+
return {
|
|
143
|
+
open,
|
|
144
|
+
halfOpen,
|
|
145
|
+
closed,
|
|
146
|
+
total: this.circuits.size,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* BUG FIX 2: Export all circuit states for persistence
|
|
151
|
+
* Returns array of { jid, state, failures, openedAt, halfOpenProbeUsed }
|
|
152
|
+
*/
|
|
153
|
+
exportState() {
|
|
154
|
+
const result = [];
|
|
155
|
+
this.circuits.forEach((entry, jid) => {
|
|
156
|
+
// Only export circuits that are open or half-open (non-trivial state)
|
|
157
|
+
if (entry.state !== 'closed' || entry.failures > 0) {
|
|
158
|
+
result.push({
|
|
159
|
+
jid,
|
|
160
|
+
state: entry.state,
|
|
161
|
+
failures: entry.failures,
|
|
162
|
+
openedAt: entry.openedAt,
|
|
163
|
+
halfOpenProbeUsed: entry.halfOpenProbeUsed,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
return result;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* BUG FIX 2: Import circuit states from persistence
|
|
171
|
+
* Restores open/half-open circuits so blocked JIDs remain blocked after restart
|
|
172
|
+
*/
|
|
173
|
+
importState(states) {
|
|
174
|
+
for (const item of states) {
|
|
175
|
+
this.circuits.set(item.jid, {
|
|
176
|
+
state: item.state,
|
|
177
|
+
failures: item.failures,
|
|
178
|
+
openedAt: item.openedAt,
|
|
179
|
+
halfOpenProbeUsed: item.halfOpenProbeUsed ?? false,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
export function createJidCircuitBreaker(config) {
|
|
185
|
+
return new JidCircuitBreaker(config);
|
|
186
|
+
}
|