@prototypevip/baileys 0.0.9 → 0.1.1

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.
Files changed (50) hide show
  1. package/lib/Socket/index.js +12 -135
  2. package/lib/Socket/messages-send.js +27 -44
  3. package/package.json +2 -2
  4. package/lib/AntiBan/antiban.js +0 -922
  5. package/lib/AntiBan/banRecoveryOrchestrator.js +0 -284
  6. package/lib/AntiBan/cli.js +0 -177
  7. package/lib/AntiBan/contactGraph.js +0 -236
  8. package/lib/AntiBan/contentVariator.js +0 -150
  9. package/lib/AntiBan/credsSnapshot.js +0 -121
  10. package/lib/AntiBan/deliveryTracker.js +0 -107
  11. package/lib/AntiBan/deviceFingerprint.js +0 -106
  12. package/lib/AntiBan/fleetEventStore.js +0 -157
  13. package/lib/AntiBan/groupOperationGuard.js +0 -116
  14. package/lib/AntiBan/health.js +0 -207
  15. package/lib/AntiBan/humanEntropy.js +0 -337
  16. package/lib/AntiBan/index.js +0 -77
  17. package/lib/AntiBan/instanceCoordinator.js +0 -175
  18. package/lib/AntiBan/jidCanonicalizer.js +0 -256
  19. package/lib/AntiBan/jidCircuitBreaker.js +0 -186
  20. package/lib/AntiBan/legitimacySignalInjector.js +0 -231
  21. package/lib/AntiBan/lidFirstResolver.js +0 -174
  22. package/lib/AntiBan/lidResolver.js +0 -324
  23. package/lib/AntiBan/messageQueue.js +0 -187
  24. package/lib/AntiBan/messageRecovery.js +0 -283
  25. package/lib/AntiBan/messageTypeRegistry.js +0 -369
  26. package/lib/AntiBan/observability.js +0 -145
  27. package/lib/AntiBan/patch.js +0 -302
  28. package/lib/AntiBan/persist.js +0 -87
  29. package/lib/AntiBan/presenceChoreographer.js +0 -430
  30. package/lib/AntiBan/presets.js +0 -105
  31. package/lib/AntiBan/profiles.js +0 -31
  32. package/lib/AntiBan/proxyRotator.js +0 -306
  33. package/lib/AntiBan/rateLimiter.js +0 -261
  34. package/lib/AntiBan/readReceiptVariance.js +0 -88
  35. package/lib/AntiBan/reconnectThrottle.js +0 -180
  36. package/lib/AntiBan/replyRatio.js +0 -161
  37. package/lib/AntiBan/reputationVoucher.js +0 -373
  38. package/lib/AntiBan/retryReason.js +0 -91
  39. package/lib/AntiBan/retryTracker.js +0 -172
  40. package/lib/AntiBan/scheduler.js +0 -111
  41. package/lib/AntiBan/sessionFingerprint.js +0 -248
  42. package/lib/AntiBan/sessionStability.js +0 -330
  43. package/lib/AntiBan/stateAdapter.js +0 -73
  44. package/lib/AntiBan/stateExport.js +0 -152
  45. package/lib/AntiBan/stealthConnect.js +0 -130
  46. package/lib/AntiBan/timelockGuard.js +0 -181
  47. package/lib/AntiBan/topologyThrottler.js +0 -339
  48. package/lib/AntiBan/warmup.js +0 -140
  49. package/lib/AntiBan/webhooks.js +0 -80
  50. package/lib/AntiBan/wrapper.js +0 -456
@@ -1,77 +0,0 @@
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';
@@ -1,175 +0,0 @@
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
- }
@@ -1,256 +0,0 @@
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
- }
@@ -1,186 +0,0 @@
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
- }