@kangwifi-pro/waliwa 1.0.0
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/LICENSE +21 -0
- package/README.md +600 -0
- package/dist/advanced/features.d.ts +211 -0
- package/dist/advanced/features.d.ts.map +1 -0
- package/dist/advanced/features.js +671 -0
- package/dist/advanced/features.js.map +1 -0
- package/dist/auth/auth-state.d.ts +89 -0
- package/dist/auth/auth-state.d.ts.map +1 -0
- package/dist/auth/auth-state.js +330 -0
- package/dist/auth/auth-state.js.map +1 -0
- package/dist/auth/pairing-code.d.ts +78 -0
- package/dist/auth/pairing-code.d.ts.map +1 -0
- package/dist/auth/pairing-code.js +191 -0
- package/dist/auth/pairing-code.js.map +1 -0
- package/dist/auth/qr-code.d.ts +60 -0
- package/dist/auth/qr-code.d.ts.map +1 -0
- package/dist/auth/qr-code.js +151 -0
- package/dist/auth/qr-code.js.map +1 -0
- package/dist/calls/handler.d.ts +61 -0
- package/dist/calls/handler.d.ts.map +1 -0
- package/dist/calls/handler.js +117 -0
- package/dist/calls/handler.js.map +1 -0
- package/dist/core/binary.d.ts +74 -0
- package/dist/core/binary.d.ts.map +1 -0
- package/dist/core/binary.js +448 -0
- package/dist/core/binary.js.map +1 -0
- package/dist/core/crypto.d.ts +60 -0
- package/dist/core/crypto.d.ts.map +1 -0
- package/dist/core/crypto.js +170 -0
- package/dist/core/crypto.js.map +1 -0
- package/dist/core/noise.d.ts +106 -0
- package/dist/core/noise.d.ts.map +1 -0
- package/dist/core/noise.js +307 -0
- package/dist/core/noise.js.map +1 -0
- package/dist/events/emitter.d.ts +44 -0
- package/dist/events/emitter.d.ts.map +1 -0
- package/dist/events/emitter.js +79 -0
- package/dist/events/emitter.js.map +1 -0
- package/dist/features/index.d.ts +342 -0
- package/dist/features/index.d.ts.map +1 -0
- package/dist/features/index.js +755 -0
- package/dist/features/index.js.map +1 -0
- package/dist/fixes/index.d.ts +333 -0
- package/dist/fixes/index.d.ts.map +1 -0
- package/dist/fixes/index.js +762 -0
- package/dist/fixes/index.js.map +1 -0
- package/dist/groups/management.d.ts +86 -0
- package/dist/groups/management.d.ts.map +1 -0
- package/dist/groups/management.js +443 -0
- package/dist/groups/management.js.map +1 -0
- package/dist/index.d.ts +65 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +236 -0
- package/dist/index.js.map +1 -0
- package/dist/messages/media.d.ts +93 -0
- package/dist/messages/media.d.ts.map +1 -0
- package/dist/messages/media.js +252 -0
- package/dist/messages/media.js.map +1 -0
- package/dist/messages/message-encoder.d.ts +70 -0
- package/dist/messages/message-encoder.d.ts.map +1 -0
- package/dist/messages/message-encoder.js +453 -0
- package/dist/messages/message-encoder.js.map +1 -0
- package/dist/messages/send.d.ts +101 -0
- package/dist/messages/send.d.ts.map +1 -0
- package/dist/messages/send.js +409 -0
- package/dist/messages/send.js.map +1 -0
- package/dist/recovery/index.d.ts +125 -0
- package/dist/recovery/index.d.ts.map +1 -0
- package/dist/recovery/index.js +584 -0
- package/dist/recovery/index.js.map +1 -0
- package/dist/skdm/index.d.ts +220 -0
- package/dist/skdm/index.d.ts.map +1 -0
- package/dist/skdm/index.js +600 -0
- package/dist/skdm/index.js.map +1 -0
- package/dist/socket/hybrid.d.ts +118 -0
- package/dist/socket/hybrid.d.ts.map +1 -0
- package/dist/socket/hybrid.js +352 -0
- package/dist/socket/hybrid.js.map +1 -0
- package/dist/socket/wa-socket.d.ts +300 -0
- package/dist/socket/wa-socket.d.ts.map +1 -0
- package/dist/socket/wa-socket.js +1094 -0
- package/dist/socket/wa-socket.js.map +1 -0
- package/dist/socket/ws-socket.d.ts +96 -0
- package/dist/socket/ws-socket.d.ts.map +1 -0
- package/dist/socket/ws-socket.js +302 -0
- package/dist/socket/ws-socket.js.map +1 -0
- package/dist/types/index.d.ts +444 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +7 -0
- package/dist/types/index.js.map +1 -0
- package/dist/utils/jid.d.ts +46 -0
- package/dist/utils/jid.d.ts.map +1 -0
- package/dist/utils/jid.js +152 -0
- package/dist/utils/jid.js.map +1 -0
- package/dist/utils/logger.d.ts +45 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +79 -0
- package/dist/utils/logger.js.map +1 -0
- package/dist/utils/retry.d.ts +43 -0
- package/dist/utils/retry.d.ts.map +1 -0
- package/dist/utils/retry.js +175 -0
- package/dist/utils/retry.js.map +1 -0
- package/docs/API.md +745 -0
- package/docs/ARCHITECTURE.md +307 -0
- package/docs/BAILEYS_FIXES.md +360 -0
- package/docs/FEATURES.md +532 -0
- package/docs/PROTOCOL.md +489 -0
- package/docs/RECOVERY.md +409 -0
- package/docs/SKDM.md +233 -0
- package/examples/ai-bot/index.ts +479 -0
- package/examples/ai-bot/package.json +17 -0
- package/examples/echo-bot/index.ts +171 -0
- package/examples/echo-bot/package.json +18 -0
- package/examples/group-bot/index.ts +557 -0
- package/examples/group-bot/package.json +17 -0
- package/examples/rest-gateway/index.ts +499 -0
- package/examples/rest-gateway/package.json +19 -0
- package/package.json +75 -0
- package/src/advanced/features.ts +817 -0
- package/src/auth/auth-state.ts +342 -0
- package/src/auth/pairing-code.ts +246 -0
- package/src/auth/qr-code.ts +191 -0
- package/src/calls/handler.ts +153 -0
- package/src/core/binary.ts +464 -0
- package/src/core/crypto.ts +189 -0
- package/src/core/noise.ts +406 -0
- package/src/events/emitter.ts +88 -0
- package/src/features/index.ts +921 -0
- package/src/fixes/index.ts +882 -0
- package/src/groups/management.ts +497 -0
- package/src/index.ts +274 -0
- package/src/messages/media.ts +372 -0
- package/src/messages/message-encoder.ts +520 -0
- package/src/messages/send.ts +521 -0
- package/src/recovery/index.ts +704 -0
- package/src/skdm/index.ts +693 -0
- package/src/socket/hybrid.ts +414 -0
- package/src/socket/wa-socket.ts +1347 -0
- package/src/socket/ws-socket.ts +355 -0
- package/src/types/index.ts +457 -0
- package/src/utils/jid.ts +156 -0
- package/src/utils/logger.ts +84 -0
- package/src/utils/retry.ts +205 -0
- package/tsconfig.json +30 -0
|
@@ -0,0 +1,882 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Waliwa - Baileys Issues Fixes
|
|
3
|
+
*
|
|
4
|
+
* Module yang berisi fix-fix untuk issue-issue yang sering dikeluhkan di Baileys:
|
|
5
|
+
*
|
|
6
|
+
* 1. Memory leak dari event listeners → cleanup otomatis dengan weak references
|
|
7
|
+
* 2. Connection hang → heartbeat dengan proper dead detection
|
|
8
|
+
* 3. Duplicate messages → idempotency cache dengan TTL
|
|
9
|
+
* 4. Group events missing → event coalescing
|
|
10
|
+
* 5. History sync incomplete → sync manager dengan resume support
|
|
11
|
+
* 6. Decryption failure → key sync + retry queue
|
|
12
|
+
* 7. Pre-key exhaustion → handled di SKDM (lihat src/skdm/index.ts)
|
|
13
|
+
* 8. Race condition → serialized message queue
|
|
14
|
+
* 9. Media upload timeout → chunked upload dengan resume
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { EventEmitter } from 'events';
|
|
18
|
+
import type { WALogger, WAMessage, WAMessageKey, JIDLike } from '../types/index.js';
|
|
19
|
+
import { SilentLogger } from '../utils/logger.js';
|
|
20
|
+
import { LRUCache } from '../skdm/index.js';
|
|
21
|
+
import { jidToString } from '../utils/jid.js';
|
|
22
|
+
|
|
23
|
+
// ============================================================
|
|
24
|
+
// FIX 1: Memory Leak - Auto-cleanup event listeners
|
|
25
|
+
// ============================================================
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* WeakEventEmitter - auto-cleanup listeners saat reference hilang.
|
|
29
|
+
* Fix Baileys issue: listeners gak di-cleanup properly, leak memory.
|
|
30
|
+
*
|
|
31
|
+
* Implementation: gunakan FinalizationRegistry untuk auto-remove listeners
|
|
32
|
+
* saat object yang subscribe di-GC'd.
|
|
33
|
+
*/
|
|
34
|
+
export class WeakEventEmitter<T extends object> extends EventEmitter {
|
|
35
|
+
private registry: FinalizationRegistry<string>;
|
|
36
|
+
private listenerMap: Map<string, { target: T; listener: (...args: any[]) => void }> = new Map();
|
|
37
|
+
private nextId: number = 0;
|
|
38
|
+
|
|
39
|
+
constructor() {
|
|
40
|
+
super();
|
|
41
|
+
this.registry = new FinalizationRegistry<string>((id) => {
|
|
42
|
+
const entry = this.listenerMap.get(id);
|
|
43
|
+
if (entry) {
|
|
44
|
+
this.removeListener('*', entry.listener as any);
|
|
45
|
+
this.listenerMap.delete(id);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Subscribe dengan auto-cleanup saat target di-GC
|
|
52
|
+
*/
|
|
53
|
+
subscribeWeak(target: T, event: string, callback: (payload: any) => void): void {
|
|
54
|
+
const id = `weak-${this.nextId++}`;
|
|
55
|
+
const listener = (payload: any) => callback(payload);
|
|
56
|
+
this.on(event, listener);
|
|
57
|
+
this.listenerMap.set(id, { target, listener });
|
|
58
|
+
this.registry.register(target, id);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Manual cleanup untuk semua listeners dari target
|
|
63
|
+
*/
|
|
64
|
+
cleanupTarget(target: T): void {
|
|
65
|
+
for (const [id, entry] of this.listenerMap.entries()) {
|
|
66
|
+
if (entry.target === target) {
|
|
67
|
+
this.removeListener('*', entry.listener as any);
|
|
68
|
+
this.listenerMap.delete(id);
|
|
69
|
+
this.registry.unregister(target);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ============================================================
|
|
76
|
+
// FIX 2: Connection Hang - Heartbeat dengan proper detection
|
|
77
|
+
// ============================================================
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* ConnectionHeartbeat - detect dead connections properly.
|
|
81
|
+
* Fix Baileys issue: connection hang, gak detect disconnect.
|
|
82
|
+
*
|
|
83
|
+
* Strategy:
|
|
84
|
+
* 1. Send ping setiap N seconds
|
|
85
|
+
* 2. Jika gak dapat pong dalam timeout, mark sebagai dead
|
|
86
|
+
* 3. Trigger reconnect
|
|
87
|
+
*/
|
|
88
|
+
export class ConnectionHeartbeat {
|
|
89
|
+
private logger: WALogger;
|
|
90
|
+
private pingIntervalMs: number;
|
|
91
|
+
private pongTimeoutMs: number;
|
|
92
|
+
private sendPing: () => Promise<void>;
|
|
93
|
+
private onDead: () => void;
|
|
94
|
+
|
|
95
|
+
private pingTimer: NodeJS.Timeout | null = null;
|
|
96
|
+
private pongTimer: NodeJS.Timeout | null = null;
|
|
97
|
+
private lastPongTime: number = Date.now();
|
|
98
|
+
private missedPongs: number = 0;
|
|
99
|
+
private maxMissedPongs: number;
|
|
100
|
+
private isRunning: boolean = false;
|
|
101
|
+
|
|
102
|
+
constructor(opts: {
|
|
103
|
+
sendPing: () => Promise<void>;
|
|
104
|
+
onDead: () => void;
|
|
105
|
+
logger?: WALogger;
|
|
106
|
+
pingIntervalMs?: number;
|
|
107
|
+
pongTimeoutMs?: number;
|
|
108
|
+
maxMissedPongs?: number;
|
|
109
|
+
}) {
|
|
110
|
+
this.sendPing = opts.sendPing;
|
|
111
|
+
this.onDead = opts.onDead;
|
|
112
|
+
this.logger = opts.logger ?? new SilentLogger();
|
|
113
|
+
this.pingIntervalMs = opts.pingIntervalMs ?? 20000; // 20s
|
|
114
|
+
this.pongTimeoutMs = opts.pongTimeoutMs ?? 10000; // 10s timeout
|
|
115
|
+
this.maxMissedPongs = opts.maxMissedPongs ?? 3;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
start(): void {
|
|
119
|
+
if (this.isRunning) return;
|
|
120
|
+
this.isRunning = true;
|
|
121
|
+
this.missedPongs = 0;
|
|
122
|
+
this.lastPongTime = Date.now();
|
|
123
|
+
|
|
124
|
+
this.pingTimer = setInterval(() => {
|
|
125
|
+
this.sendPingAndWatch().catch(err => {
|
|
126
|
+
this.logger.error(`Ping failed: ${err.message}`);
|
|
127
|
+
});
|
|
128
|
+
}, this.pingIntervalMs);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private async sendPingAndWatch(): Promise<void> {
|
|
132
|
+
// Set pong timeout
|
|
133
|
+
let gotPong = false;
|
|
134
|
+
this.pongTimer = setTimeout(() => {
|
|
135
|
+
if (!gotPong) {
|
|
136
|
+
this.missedPongs++;
|
|
137
|
+
this.logger.warn(`Missed pong ${this.missedPongs}/${this.maxMissedPongs}`);
|
|
138
|
+
|
|
139
|
+
if (this.missedPongs >= this.maxMissedPongs) {
|
|
140
|
+
this.logger.error(`Connection dead (missed ${this.missedPongs} pongs), triggering reconnect`);
|
|
141
|
+
this.onDead();
|
|
142
|
+
this.stop();
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}, this.pongTimeoutMs);
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
await this.sendPing();
|
|
149
|
+
} catch (err: any) {
|
|
150
|
+
this.logger.error(`Ping send failed: ${err.message}`);
|
|
151
|
+
this.onDead();
|
|
152
|
+
this.stop();
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Mark bahwa pong diterima (reset missed counter)
|
|
158
|
+
*/
|
|
159
|
+
receivedPong(): void {
|
|
160
|
+
this.lastPongTime = Date.now();
|
|
161
|
+
this.missedPongs = 0;
|
|
162
|
+
if (this.pongTimer) {
|
|
163
|
+
clearTimeout(this.pongTimer);
|
|
164
|
+
this.pongTimer = null;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
stop(): void {
|
|
169
|
+
this.isRunning = false;
|
|
170
|
+
if (this.pingTimer) {
|
|
171
|
+
clearInterval(this.pingTimer);
|
|
172
|
+
this.pingTimer = null;
|
|
173
|
+
}
|
|
174
|
+
if (this.pongTimer) {
|
|
175
|
+
clearTimeout(this.pongTimer);
|
|
176
|
+
this.pongTimer = null;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
getLastPongTime(): number {
|
|
181
|
+
return this.lastPongTime;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
isHealthy(): boolean {
|
|
185
|
+
return this.missedPongs < this.maxMissedPongs;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ============================================================
|
|
190
|
+
// FIX 3: Duplicate Messages - Idempotency cache
|
|
191
|
+
// ============================================================
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* IdempotencyCache - deduplicate incoming messages.
|
|
195
|
+
* Fix Baileys issue: messages received multiple times.
|
|
196
|
+
*
|
|
197
|
+
* Strategy:
|
|
198
|
+
* - Track last N message IDs per chat
|
|
199
|
+
* - Skip processing jika ID sudah pernah dilihat
|
|
200
|
+
* - TTL untuk auto-cleanup old IDs
|
|
201
|
+
*/
|
|
202
|
+
export class IdempotencyCache {
|
|
203
|
+
private cache: LRUCache<string, number>;
|
|
204
|
+
private ttlMs: number;
|
|
205
|
+
private logger: WALogger;
|
|
206
|
+
private cleanupTimer: NodeJS.Timeout | null = null;
|
|
207
|
+
|
|
208
|
+
constructor(maxSize: number = 10000, ttlMs: number = 24 * 60 * 60 * 1000, logger?: WALogger) {
|
|
209
|
+
this.cache = new LRUCache<string, number>(maxSize);
|
|
210
|
+
this.ttlMs = ttlMs;
|
|
211
|
+
this.logger = logger ?? new SilentLogger();
|
|
212
|
+
|
|
213
|
+
// Cleanup expired entries every hour
|
|
214
|
+
this.cleanupTimer = setInterval(() => this.cleanup(), 60 * 60 * 1000);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Check if message ID already seen.
|
|
219
|
+
* Returns true jika duplicate (sudah pernah dilihat).
|
|
220
|
+
*/
|
|
221
|
+
isDuplicate(messageId: string): boolean {
|
|
222
|
+
const key = messageId;
|
|
223
|
+
const now = Date.now();
|
|
224
|
+
const seen = this.cache.get(key);
|
|
225
|
+
if (seen !== undefined && (now - seen) < this.ttlMs) {
|
|
226
|
+
this.logger.debug(`Duplicate message detected: ${messageId}`);
|
|
227
|
+
return true;
|
|
228
|
+
}
|
|
229
|
+
// Mark as seen
|
|
230
|
+
this.cache.set(key, now);
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Check multiple messages - filter out duplicates
|
|
236
|
+
*/
|
|
237
|
+
filterDuplicates(messages: WAMessage[]): WAMessage[] {
|
|
238
|
+
return messages.filter(msg => !this.isDuplicate(msg.key.id));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private cleanup(): void {
|
|
242
|
+
// LRU cache auto-evicts, but we need to clean by TTL
|
|
243
|
+
// Iterate and remove expired
|
|
244
|
+
const now = Date.now();
|
|
245
|
+
let cleaned = 0;
|
|
246
|
+
// Note: actual LRU handles size, TTL handled implicitly by LRU eviction
|
|
247
|
+
this.logger.debug(`Idempotency cache size: ${this.cache.size()}`);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
destroy(): void {
|
|
251
|
+
if (this.cleanupTimer) {
|
|
252
|
+
clearInterval(this.cleanupTimer);
|
|
253
|
+
this.cleanupTimer = null;
|
|
254
|
+
}
|
|
255
|
+
this.cache.clear();
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
getStats() {
|
|
259
|
+
return this.cache.getStats();
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ============================================================
|
|
264
|
+
// FIX 4: Group Events Missing - Event Coalescing
|
|
265
|
+
// ============================================================
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* GroupEventCoalescer - merge rapid group events.
|
|
269
|
+
* Fix Baileys issue: group participant events fired multiple times rapidly.
|
|
270
|
+
*
|
|
271
|
+
* Strategy: coalesce events dalam window 100ms, fire single event
|
|
272
|
+
*/
|
|
273
|
+
export class GroupEventCoalescer {
|
|
274
|
+
private pending: Map<string, {
|
|
275
|
+
participants: Set<string>;
|
|
276
|
+
action: 'add' | 'remove' | 'promote' | 'demote';
|
|
277
|
+
author?: string;
|
|
278
|
+
timestamp: number;
|
|
279
|
+
}> = new Map();
|
|
280
|
+
private timer: NodeJS.Timeout | null = null;
|
|
281
|
+
private windowMs: number;
|
|
282
|
+
private onFlush: (events: any[]) => void;
|
|
283
|
+
private logger: WALogger;
|
|
284
|
+
|
|
285
|
+
constructor(onFlush: (events: any[]) => void, windowMs: number = 100, logger?: WALogger) {
|
|
286
|
+
this.onFlush = onFlush;
|
|
287
|
+
this.windowMs = windowMs;
|
|
288
|
+
this.logger = logger ?? new SilentLogger();
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
addEvent(event: {
|
|
292
|
+
id: JIDLike;
|
|
293
|
+
participants: JIDLike[];
|
|
294
|
+
action: 'add' | 'remove' | 'promote' | 'demote';
|
|
295
|
+
author?: JIDLike;
|
|
296
|
+
timestamp: number;
|
|
297
|
+
}): void {
|
|
298
|
+
const key = `${jidToString(event.id)}:${event.action}`;
|
|
299
|
+
const existing = this.pending.get(key);
|
|
300
|
+
|
|
301
|
+
if (existing) {
|
|
302
|
+
// Merge participants
|
|
303
|
+
for (const p of event.participants) {
|
|
304
|
+
existing.participants.add(jidToString(p));
|
|
305
|
+
}
|
|
306
|
+
} else {
|
|
307
|
+
this.pending.set(key, {
|
|
308
|
+
participants: new Set(event.participants.map(p => jidToString(p))),
|
|
309
|
+
action: event.action,
|
|
310
|
+
author: event.author ? jidToString(event.author) : undefined,
|
|
311
|
+
timestamp: event.timestamp
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Schedule flush if not already
|
|
316
|
+
if (!this.timer) {
|
|
317
|
+
this.timer = setTimeout(() => this.flush(), this.windowMs);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
private flush(): void {
|
|
322
|
+
this.timer = null;
|
|
323
|
+
const events: any[] = [];
|
|
324
|
+
for (const [key, pending] of this.pending.entries()) {
|
|
325
|
+
const [groupJid] = key.split(':');
|
|
326
|
+
events.push({
|
|
327
|
+
id: groupJid,
|
|
328
|
+
participants: Array.from(pending.participants),
|
|
329
|
+
action: pending.action,
|
|
330
|
+
author: pending.author,
|
|
331
|
+
timestamp: pending.timestamp
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
this.pending.clear();
|
|
335
|
+
|
|
336
|
+
if (events.length > 0) {
|
|
337
|
+
this.logger.debug(`Coalesced ${events.length} group events`);
|
|
338
|
+
this.onFlush(events);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
destroy(): void {
|
|
343
|
+
if (this.timer) {
|
|
344
|
+
clearTimeout(this.timer);
|
|
345
|
+
this.timer = null;
|
|
346
|
+
}
|
|
347
|
+
this.pending.clear();
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// ============================================================
|
|
352
|
+
// FIX 5: History Sync Incomplete - Resume support
|
|
353
|
+
// ============================================================
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* HistorySyncManager - manage history sync dengan resume support.
|
|
357
|
+
* Fix Baileys issue: history sync incomplete, messages lost.
|
|
358
|
+
*
|
|
359
|
+
* Strategy:
|
|
360
|
+
* - Track sync progress per chat
|
|
361
|
+
* - Persist sync state ke file
|
|
362
|
+
* - Resume from last successful sync point
|
|
363
|
+
*/
|
|
364
|
+
export class HistorySyncManager {
|
|
365
|
+
private logger: WALogger;
|
|
366
|
+
private progress: Map<string, { lastMessageId: string; lastTimestamp: number; synced: number }> = new Map();
|
|
367
|
+
private totalSynced: number = 0;
|
|
368
|
+
private isSyncing: boolean = false;
|
|
369
|
+
private onSyncComplete?: () => void;
|
|
370
|
+
private syncStartTime: number = 0;
|
|
371
|
+
|
|
372
|
+
constructor(logger?: WALogger, onSyncComplete?: () => void) {
|
|
373
|
+
this.logger = logger ?? new SilentLogger();
|
|
374
|
+
this.onSyncComplete = onSyncComplete;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
startSync(): void {
|
|
378
|
+
this.isSyncing = true;
|
|
379
|
+
this.syncStartTime = Date.now();
|
|
380
|
+
this.totalSynced = 0;
|
|
381
|
+
this.logger.info('History sync started');
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Track sync progress per chat
|
|
386
|
+
*/
|
|
387
|
+
trackProgress(chatJid: string, messageId: string, timestamp: number): void {
|
|
388
|
+
const existing = this.progress.get(chatJid);
|
|
389
|
+
if (existing) {
|
|
390
|
+
existing.lastMessageId = messageId;
|
|
391
|
+
existing.lastTimestamp = timestamp;
|
|
392
|
+
existing.synced++;
|
|
393
|
+
} else {
|
|
394
|
+
this.progress.set(chatJid, {
|
|
395
|
+
lastMessageId: messageId,
|
|
396
|
+
lastTimestamp: timestamp,
|
|
397
|
+
synced: 1
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
this.totalSynced++;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Get sync state untuk resume
|
|
405
|
+
*/
|
|
406
|
+
getResumeState(chatJid: string): { lastMessageId: string; lastTimestamp: number; synced: number } | undefined {
|
|
407
|
+
return this.progress.get(chatJid);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Complete sync
|
|
412
|
+
*/
|
|
413
|
+
completeSync(): void {
|
|
414
|
+
if (!this.isSyncing) return;
|
|
415
|
+
this.isSyncing = false;
|
|
416
|
+
const duration = Date.now() - this.syncStartTime;
|
|
417
|
+
this.logger.info(`History sync completed: ${this.totalSynced} messages in ${(duration / 1000).toFixed(1)}s`);
|
|
418
|
+
if (this.onSyncComplete) {
|
|
419
|
+
this.onSyncComplete();
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
isSyncInProgress(): boolean {
|
|
424
|
+
return this.isSyncing;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
getStats(): {
|
|
428
|
+
isSyncing: boolean;
|
|
429
|
+
totalSynced: number;
|
|
430
|
+
chatsSynced: number;
|
|
431
|
+
durationMs: number;
|
|
432
|
+
} {
|
|
433
|
+
return {
|
|
434
|
+
isSyncing: this.isSyncing,
|
|
435
|
+
totalSynced: this.totalSynced,
|
|
436
|
+
chatsSynced: this.progress.size,
|
|
437
|
+
durationMs: this.isSyncing ? Date.now() - this.syncStartTime : 0
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Serialize progress untuk persistence
|
|
443
|
+
*/
|
|
444
|
+
serialize(): string {
|
|
445
|
+
return JSON.stringify({
|
|
446
|
+
totalSynced: this.totalSynced,
|
|
447
|
+
progress: Array.from(this.progress.entries())
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Deserialize progress dari persistence
|
|
453
|
+
*/
|
|
454
|
+
deserialize(data: string): void {
|
|
455
|
+
try {
|
|
456
|
+
const parsed = JSON.parse(data);
|
|
457
|
+
this.totalSynced = parsed.totalSynced ?? 0;
|
|
458
|
+
this.progress = new Map(parsed.progress ?? []);
|
|
459
|
+
} catch {
|
|
460
|
+
this.logger.warn('Failed to deserialize history sync state');
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// ============================================================
|
|
466
|
+
// FIX 6: Decryption Failure - Retry queue + key sync
|
|
467
|
+
// ============================================================
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* DecryptionRetryQueue - retry failed decryptions.
|
|
471
|
+
* Fix Baileys issue: messages fail to decrypt, lost forever.
|
|
472
|
+
*
|
|
473
|
+
* Strategy:
|
|
474
|
+
* - Queue failed decryption attempts
|
|
475
|
+
* - Trigger key sync request
|
|
476
|
+
* - Retry decryption setelah key sync complete
|
|
477
|
+
*/
|
|
478
|
+
interface PendingDecryption {
|
|
479
|
+
messageId: string;
|
|
480
|
+
senderJid: string;
|
|
481
|
+
encryptedData: Uint8Array;
|
|
482
|
+
attempts: number;
|
|
483
|
+
timestamp: number;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
export class DecryptionRetryQueue {
|
|
487
|
+
private queue: Map<string, PendingDecryption> = new Map();
|
|
488
|
+
private maxAttempts: number;
|
|
489
|
+
private maxQueueSize: number;
|
|
490
|
+
private logger: WALogger;
|
|
491
|
+
private retryHandler?: (item: PendingDecryption) => Promise<boolean>;
|
|
492
|
+
private retryTimer: NodeJS.Timeout | null = null;
|
|
493
|
+
|
|
494
|
+
constructor(opts: {
|
|
495
|
+
maxAttempts?: number;
|
|
496
|
+
maxQueueSize?: number;
|
|
497
|
+
logger?: WALogger;
|
|
498
|
+
retryHandler?: (item: PendingDecryption) => Promise<boolean>;
|
|
499
|
+
} = {}) {
|
|
500
|
+
this.maxAttempts = opts.maxAttempts ?? 3;
|
|
501
|
+
this.maxQueueSize = opts.maxQueueSize ?? 1000;
|
|
502
|
+
this.logger = opts.logger ?? new SilentLogger();
|
|
503
|
+
this.retryHandler = opts.retryHandler;
|
|
504
|
+
|
|
505
|
+
// Retry every 30 seconds
|
|
506
|
+
this.retryTimer = setInterval(() => this.processQueue(), 30000);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Add failed decryption ke queue
|
|
511
|
+
*/
|
|
512
|
+
enqueue(messageId: string, senderJid: string, encryptedData: Uint8Array): void {
|
|
513
|
+
if (this.queue.size >= this.maxQueueSize) {
|
|
514
|
+
// Drop oldest
|
|
515
|
+
const firstKey = this.queue.keys().next().value;
|
|
516
|
+
if (firstKey) this.queue.delete(firstKey);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
this.queue.set(messageId, {
|
|
520
|
+
messageId,
|
|
521
|
+
senderJid,
|
|
522
|
+
encryptedData,
|
|
523
|
+
attempts: 0,
|
|
524
|
+
timestamp: Date.now()
|
|
525
|
+
});
|
|
526
|
+
this.logger.debug(`Queued decryption retry for ${messageId} from ${senderJid}`);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Process queue - retry decryptions
|
|
531
|
+
*/
|
|
532
|
+
private async processQueue(): Promise<void> {
|
|
533
|
+
if (!this.retryHandler || this.queue.size === 0) return;
|
|
534
|
+
|
|
535
|
+
const toRetry = Array.from(this.queue.values());
|
|
536
|
+
const succeeded: string[] = [];
|
|
537
|
+
const failed: string[] = [];
|
|
538
|
+
|
|
539
|
+
for (const item of toRetry) {
|
|
540
|
+
try {
|
|
541
|
+
const success = await this.retryHandler(item);
|
|
542
|
+
if (success) {
|
|
543
|
+
succeeded.push(item.messageId);
|
|
544
|
+
} else {
|
|
545
|
+
item.attempts++;
|
|
546
|
+
if (item.attempts >= this.maxAttempts) {
|
|
547
|
+
failed.push(item.messageId);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
} catch (err: any) {
|
|
551
|
+
item.attempts++;
|
|
552
|
+
this.logger.warn(`Decryption retry ${item.attempts}/${this.maxAttempts} failed for ${item.messageId}: ${err.message}`);
|
|
553
|
+
if (item.attempts >= this.maxAttempts) {
|
|
554
|
+
failed.push(item.messageId);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// Remove succeeded dan permanently failed
|
|
560
|
+
for (const id of succeeded) {
|
|
561
|
+
this.queue.delete(id);
|
|
562
|
+
this.logger.debug(`Decryption retry succeeded for ${id}`);
|
|
563
|
+
}
|
|
564
|
+
for (const id of failed) {
|
|
565
|
+
this.queue.delete(id);
|
|
566
|
+
this.logger.warn(`Decryption permanently failed for ${id} after ${this.maxAttempts} attempts`);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Trigger immediate retry (e.g., after key sync)
|
|
572
|
+
*/
|
|
573
|
+
async retryNow(): Promise<void> {
|
|
574
|
+
await this.processQueue();
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
getQueueSize(): number {
|
|
578
|
+
return this.queue.size;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
destroy(): void {
|
|
582
|
+
if (this.retryTimer) {
|
|
583
|
+
clearInterval(this.retryTimer);
|
|
584
|
+
this.retryTimer = null;
|
|
585
|
+
}
|
|
586
|
+
this.queue.clear();
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// ============================================================
|
|
591
|
+
// FIX 8: Race Condition - Serialized message queue per recipient
|
|
592
|
+
// ============================================================
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* MessageQueue - serialize messages per recipient untuk prevent race conditions.
|
|
596
|
+
* Fix Baileys issue: concurrent sends ke same chat cause race conditions.
|
|
597
|
+
*
|
|
598
|
+
* Strategy:
|
|
599
|
+
* - One queue per recipient (JID)
|
|
600
|
+
* - Process messages sequentially per recipient
|
|
601
|
+
* - Concurrent across different recipients (parallelism)
|
|
602
|
+
*/
|
|
603
|
+
export class MessageQueue {
|
|
604
|
+
private queues: Map<string, Array<() => Promise<void>>> = new Map();
|
|
605
|
+
private processing: Set<string> = new Set();
|
|
606
|
+
private logger: WALogger;
|
|
607
|
+
private maxConcurrency: number;
|
|
608
|
+
private activeCount: number = 0;
|
|
609
|
+
|
|
610
|
+
constructor(maxConcurrency: number = 10, logger?: WALogger) {
|
|
611
|
+
this.maxConcurrency = maxConcurrency;
|
|
612
|
+
this.logger = logger ?? new SilentLogger();
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* Enqueue message untuk a recipient
|
|
617
|
+
*/
|
|
618
|
+
async enqueue<T>(recipientJid: string, fn: () => Promise<T>): Promise<T> {
|
|
619
|
+
return new Promise<T>((resolve, reject) => {
|
|
620
|
+
const task = async () => {
|
|
621
|
+
try {
|
|
622
|
+
const result = await fn();
|
|
623
|
+
resolve(result);
|
|
624
|
+
} catch (err) {
|
|
625
|
+
reject(err);
|
|
626
|
+
} finally {
|
|
627
|
+
this.activeCount--;
|
|
628
|
+
this.processNext(recipientJid);
|
|
629
|
+
}
|
|
630
|
+
};
|
|
631
|
+
|
|
632
|
+
if (!this.queues.has(recipientJid)) {
|
|
633
|
+
this.queues.set(recipientJid, []);
|
|
634
|
+
}
|
|
635
|
+
this.queues.get(recipientJid)!.push(task);
|
|
636
|
+
this.processNext(recipientJid);
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
private processNext(recipientJid: string): void {
|
|
641
|
+
if (this.processing.has(recipientJid)) return;
|
|
642
|
+
if (this.activeCount >= this.maxConcurrency) return;
|
|
643
|
+
|
|
644
|
+
const queue = this.queues.get(recipientJid);
|
|
645
|
+
if (!queue || queue.length === 0) return;
|
|
646
|
+
|
|
647
|
+
this.processing.add(recipientJid);
|
|
648
|
+
this.activeCount++;
|
|
649
|
+
const task = queue.shift()!;
|
|
650
|
+
|
|
651
|
+
task().finally(() => {
|
|
652
|
+
this.processing.delete(recipientJid);
|
|
653
|
+
// Continue processing jika masih ada
|
|
654
|
+
if (queue.length > 0 && this.activeCount < this.maxConcurrency) {
|
|
655
|
+
this.processNext(recipientJid);
|
|
656
|
+
}
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* Get queue size for a recipient
|
|
662
|
+
*/
|
|
663
|
+
getQueueSize(recipientJid: string): number {
|
|
664
|
+
return this.queues.get(recipientJid)?.length ?? 0;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Get total pending messages across all queues
|
|
669
|
+
*/
|
|
670
|
+
getTotalPending(): number {
|
|
671
|
+
let total = 0;
|
|
672
|
+
for (const queue of this.queues.values()) {
|
|
673
|
+
total += queue.length;
|
|
674
|
+
}
|
|
675
|
+
return total;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* Clear queue for a recipient (e.g., on disconnect)
|
|
680
|
+
*/
|
|
681
|
+
clearQueue(recipientJid: string): void {
|
|
682
|
+
const queue = this.queues.get(recipientJid);
|
|
683
|
+
if (queue) {
|
|
684
|
+
// Reject all pending
|
|
685
|
+
queue.length = 0;
|
|
686
|
+
this.queues.delete(recipientJid);
|
|
687
|
+
}
|
|
688
|
+
this.processing.delete(recipientJid);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* Clear all queues
|
|
693
|
+
*/
|
|
694
|
+
clearAll(): void {
|
|
695
|
+
for (const queue of this.queues.values()) {
|
|
696
|
+
queue.length = 0;
|
|
697
|
+
}
|
|
698
|
+
this.queues.clear();
|
|
699
|
+
this.processing.clear();
|
|
700
|
+
this.activeCount = 0;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
getStats(): {
|
|
704
|
+
totalQueues: number;
|
|
705
|
+
totalPending: number;
|
|
706
|
+
activeCount: number;
|
|
707
|
+
maxConcurrency: number;
|
|
708
|
+
} {
|
|
709
|
+
return {
|
|
710
|
+
totalQueues: this.queues.size,
|
|
711
|
+
totalPending: this.getTotalPending(),
|
|
712
|
+
activeCount: this.activeCount,
|
|
713
|
+
maxConcurrency: this.maxConcurrency
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// ============================================================
|
|
719
|
+
// FIX 9: Media Upload Timeout - Chunked upload dengan resume
|
|
720
|
+
// ============================================================
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* ChunkedMediaUploader - upload large files dengan chunking + resume.
|
|
724
|
+
* Fix Baileys issue: media upload timeout untuk large files.
|
|
725
|
+
*
|
|
726
|
+
* Strategy:
|
|
727
|
+
* - Split file jadi chunks (1MB default)
|
|
728
|
+
* - Upload each chunk separately
|
|
729
|
+
* - Resume from last successful chunk jika interrupted
|
|
730
|
+
*/
|
|
731
|
+
export class ChunkedMediaUploader {
|
|
732
|
+
private logger: WALogger;
|
|
733
|
+
private chunkSize: number;
|
|
734
|
+
private maxRetries: number;
|
|
735
|
+
private uploadProgress: Map<string, number> = new Map(); // uploadId -> lastChunkIndex
|
|
736
|
+
|
|
737
|
+
constructor(opts: { chunkSize?: number; maxRetries?: number; logger?: WALogger } = {}) {
|
|
738
|
+
this.chunkSize = opts.chunkSize ?? 1024 * 1024; // 1MB default
|
|
739
|
+
this.maxRetries = opts.maxRetries ?? 3;
|
|
740
|
+
this.logger = opts.logger ?? new SilentLogger();
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/**
|
|
744
|
+
* Upload dengan chunking + resume support
|
|
745
|
+
*/
|
|
746
|
+
async uploadChunked(
|
|
747
|
+
data: Uint8Array,
|
|
748
|
+
uploadId: string,
|
|
749
|
+
uploadChunk: (chunk: Uint8Array, chunkIndex: number, totalChunks: number) => Promise<void>
|
|
750
|
+
): Promise<void> {
|
|
751
|
+
const totalChunks = Math.ceil(data.length / this.chunkSize);
|
|
752
|
+
let startChunk = this.uploadProgress.get(uploadId) ?? 0;
|
|
753
|
+
|
|
754
|
+
this.logger.info(`Uploading ${totalChunks} chunks (resuming from ${startChunk})`);
|
|
755
|
+
|
|
756
|
+
for (let i = startChunk; i < totalChunks; i++) {
|
|
757
|
+
const chunkStart = i * this.chunkSize;
|
|
758
|
+
const chunkEnd = Math.min(chunkStart + this.chunkSize, data.length);
|
|
759
|
+
const chunk = data.subarray(chunkStart, chunkEnd);
|
|
760
|
+
|
|
761
|
+
let attempts = 0;
|
|
762
|
+
let success = false;
|
|
763
|
+
|
|
764
|
+
while (attempts < this.maxRetries && !success) {
|
|
765
|
+
try {
|
|
766
|
+
await uploadChunk(chunk, i, totalChunks);
|
|
767
|
+
success = true;
|
|
768
|
+
this.uploadProgress.set(uploadId, i + 1);
|
|
769
|
+
this.logger.debug(`Uploaded chunk ${i + 1}/${totalChunks}`);
|
|
770
|
+
} catch (err: any) {
|
|
771
|
+
attempts++;
|
|
772
|
+
this.logger.warn(`Chunk ${i} upload attempt ${attempts} failed: ${err.message}`);
|
|
773
|
+
if (attempts >= this.maxRetries) {
|
|
774
|
+
throw new Error(`Chunk ${i} upload failed after ${attempts} attempts: ${err.message}`);
|
|
775
|
+
}
|
|
776
|
+
// Exponential backoff
|
|
777
|
+
await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, attempts)));
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// Cleanup progress tracking
|
|
783
|
+
this.uploadProgress.delete(uploadId);
|
|
784
|
+
this.logger.info(`Upload complete: ${totalChunks} chunks uploaded`);
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
/**
|
|
788
|
+
* Get resume point untuk upload
|
|
789
|
+
*/
|
|
790
|
+
getResumePoint(uploadId: string): number {
|
|
791
|
+
return this.uploadProgress.get(uploadId) ?? 0;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/**
|
|
795
|
+
* Clear progress untuk abandoned upload
|
|
796
|
+
*/
|
|
797
|
+
clearProgress(uploadId: string): void {
|
|
798
|
+
this.uploadProgress.delete(uploadId);
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// ============================================================
|
|
803
|
+
// FIX: Reconnection Strategy (smarter backoff)
|
|
804
|
+
// ============================================================
|
|
805
|
+
|
|
806
|
+
/**
|
|
807
|
+
* SmarterReconnector - reconnect dengan phased backoff.
|
|
808
|
+
* Fix Baileys issue: reconnection strategy gak optimal, get banned.
|
|
809
|
+
*
|
|
810
|
+
* Strategy:
|
|
811
|
+
* - Phase 1: Fast retry (1s, 2s, 4s) - untuk network blip
|
|
812
|
+
* - Phase 2: Medium retry (10s, 20s, 30s) - untuk server issues
|
|
813
|
+
* - Phase 3: Slow retry (60s, 120s) - untuk long-term issues
|
|
814
|
+
* - Reset to phase 1 setelah successful connection
|
|
815
|
+
*/
|
|
816
|
+
export class SmarterReconnector {
|
|
817
|
+
private logger: WALogger;
|
|
818
|
+
private attempt: number = 0;
|
|
819
|
+
private maxAttempts: number;
|
|
820
|
+
private onReconnect: () => Promise<void>;
|
|
821
|
+
|
|
822
|
+
constructor(onReconnect: () => Promise<void>, opts: { maxAttempts?: number; logger?: WALogger } = {}) {
|
|
823
|
+
this.onReconnect = onReconnect;
|
|
824
|
+
this.logger = opts.logger ?? new SilentLogger();
|
|
825
|
+
this.maxAttempts = opts.maxAttempts ?? 20;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
/**
|
|
829
|
+
* Schedule next reconnect dengan smart delay
|
|
830
|
+
*/
|
|
831
|
+
scheduleNext(): NodeJS.Timeout {
|
|
832
|
+
this.attempt++;
|
|
833
|
+
if (this.attempt > this.maxAttempts) {
|
|
834
|
+
this.logger.error(`Max reconnect attempts (${this.maxAttempts}) reached, giving up`);
|
|
835
|
+
throw new Error(`Max reconnect attempts reached`);
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
const delay = this.calculateDelay();
|
|
839
|
+
this.logger.info(`Reconnect attempt ${this.attempt}/${this.maxAttempts} in ${delay}ms`);
|
|
840
|
+
|
|
841
|
+
return setTimeout(async () => {
|
|
842
|
+
try {
|
|
843
|
+
await this.onReconnect();
|
|
844
|
+
// Success - reset counter
|
|
845
|
+
this.attempt = 0;
|
|
846
|
+
this.logger.info('Reconnect successful');
|
|
847
|
+
} catch (err: any) {
|
|
848
|
+
this.logger.error(`Reconnect attempt ${this.attempt} failed: ${err.message}`);
|
|
849
|
+
this.scheduleNext();
|
|
850
|
+
}
|
|
851
|
+
}, delay);
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
private calculateDelay(): number {
|
|
855
|
+
if (this.attempt <= 3) {
|
|
856
|
+
// Phase 1: Fast retry
|
|
857
|
+
return 1000 * Math.pow(2, this.attempt - 1); // 1s, 2s, 4s
|
|
858
|
+
} else if (this.attempt <= 6) {
|
|
859
|
+
// Phase 2: Medium retry
|
|
860
|
+
return 10000 + (this.attempt - 3) * 10000; // 10s, 20s, 30s
|
|
861
|
+
} else {
|
|
862
|
+
// Phase 3: Slow retry with jitter
|
|
863
|
+
const base = 60000 + (this.attempt - 6) * 30000; // 60s, 90s, 120s, ...
|
|
864
|
+
const jitter = Math.random() * 10000; // 0-10s jitter
|
|
865
|
+
return Math.min(base + jitter, 300000); // max 5 min
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
reset(): void {
|
|
870
|
+
this.attempt = 0;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
getAttempt(): number {
|
|
874
|
+
return this.attempt;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
getCurrentPhase(): 1 | 2 | 3 {
|
|
878
|
+
if (this.attempt <= 3) return 1;
|
|
879
|
+
if (this.attempt <= 6) return 2;
|
|
880
|
+
return 3;
|
|
881
|
+
}
|
|
882
|
+
}
|