@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.
Files changed (144) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +600 -0
  3. package/dist/advanced/features.d.ts +211 -0
  4. package/dist/advanced/features.d.ts.map +1 -0
  5. package/dist/advanced/features.js +671 -0
  6. package/dist/advanced/features.js.map +1 -0
  7. package/dist/auth/auth-state.d.ts +89 -0
  8. package/dist/auth/auth-state.d.ts.map +1 -0
  9. package/dist/auth/auth-state.js +330 -0
  10. package/dist/auth/auth-state.js.map +1 -0
  11. package/dist/auth/pairing-code.d.ts +78 -0
  12. package/dist/auth/pairing-code.d.ts.map +1 -0
  13. package/dist/auth/pairing-code.js +191 -0
  14. package/dist/auth/pairing-code.js.map +1 -0
  15. package/dist/auth/qr-code.d.ts +60 -0
  16. package/dist/auth/qr-code.d.ts.map +1 -0
  17. package/dist/auth/qr-code.js +151 -0
  18. package/dist/auth/qr-code.js.map +1 -0
  19. package/dist/calls/handler.d.ts +61 -0
  20. package/dist/calls/handler.d.ts.map +1 -0
  21. package/dist/calls/handler.js +117 -0
  22. package/dist/calls/handler.js.map +1 -0
  23. package/dist/core/binary.d.ts +74 -0
  24. package/dist/core/binary.d.ts.map +1 -0
  25. package/dist/core/binary.js +448 -0
  26. package/dist/core/binary.js.map +1 -0
  27. package/dist/core/crypto.d.ts +60 -0
  28. package/dist/core/crypto.d.ts.map +1 -0
  29. package/dist/core/crypto.js +170 -0
  30. package/dist/core/crypto.js.map +1 -0
  31. package/dist/core/noise.d.ts +106 -0
  32. package/dist/core/noise.d.ts.map +1 -0
  33. package/dist/core/noise.js +307 -0
  34. package/dist/core/noise.js.map +1 -0
  35. package/dist/events/emitter.d.ts +44 -0
  36. package/dist/events/emitter.d.ts.map +1 -0
  37. package/dist/events/emitter.js +79 -0
  38. package/dist/events/emitter.js.map +1 -0
  39. package/dist/features/index.d.ts +342 -0
  40. package/dist/features/index.d.ts.map +1 -0
  41. package/dist/features/index.js +755 -0
  42. package/dist/features/index.js.map +1 -0
  43. package/dist/fixes/index.d.ts +333 -0
  44. package/dist/fixes/index.d.ts.map +1 -0
  45. package/dist/fixes/index.js +762 -0
  46. package/dist/fixes/index.js.map +1 -0
  47. package/dist/groups/management.d.ts +86 -0
  48. package/dist/groups/management.d.ts.map +1 -0
  49. package/dist/groups/management.js +443 -0
  50. package/dist/groups/management.js.map +1 -0
  51. package/dist/index.d.ts +65 -0
  52. package/dist/index.d.ts.map +1 -0
  53. package/dist/index.js +236 -0
  54. package/dist/index.js.map +1 -0
  55. package/dist/messages/media.d.ts +93 -0
  56. package/dist/messages/media.d.ts.map +1 -0
  57. package/dist/messages/media.js +252 -0
  58. package/dist/messages/media.js.map +1 -0
  59. package/dist/messages/message-encoder.d.ts +70 -0
  60. package/dist/messages/message-encoder.d.ts.map +1 -0
  61. package/dist/messages/message-encoder.js +453 -0
  62. package/dist/messages/message-encoder.js.map +1 -0
  63. package/dist/messages/send.d.ts +101 -0
  64. package/dist/messages/send.d.ts.map +1 -0
  65. package/dist/messages/send.js +409 -0
  66. package/dist/messages/send.js.map +1 -0
  67. package/dist/recovery/index.d.ts +125 -0
  68. package/dist/recovery/index.d.ts.map +1 -0
  69. package/dist/recovery/index.js +584 -0
  70. package/dist/recovery/index.js.map +1 -0
  71. package/dist/skdm/index.d.ts +220 -0
  72. package/dist/skdm/index.d.ts.map +1 -0
  73. package/dist/skdm/index.js +600 -0
  74. package/dist/skdm/index.js.map +1 -0
  75. package/dist/socket/hybrid.d.ts +118 -0
  76. package/dist/socket/hybrid.d.ts.map +1 -0
  77. package/dist/socket/hybrid.js +352 -0
  78. package/dist/socket/hybrid.js.map +1 -0
  79. package/dist/socket/wa-socket.d.ts +300 -0
  80. package/dist/socket/wa-socket.d.ts.map +1 -0
  81. package/dist/socket/wa-socket.js +1094 -0
  82. package/dist/socket/wa-socket.js.map +1 -0
  83. package/dist/socket/ws-socket.d.ts +96 -0
  84. package/dist/socket/ws-socket.d.ts.map +1 -0
  85. package/dist/socket/ws-socket.js +302 -0
  86. package/dist/socket/ws-socket.js.map +1 -0
  87. package/dist/types/index.d.ts +444 -0
  88. package/dist/types/index.d.ts.map +1 -0
  89. package/dist/types/index.js +7 -0
  90. package/dist/types/index.js.map +1 -0
  91. package/dist/utils/jid.d.ts +46 -0
  92. package/dist/utils/jid.d.ts.map +1 -0
  93. package/dist/utils/jid.js +152 -0
  94. package/dist/utils/jid.js.map +1 -0
  95. package/dist/utils/logger.d.ts +45 -0
  96. package/dist/utils/logger.d.ts.map +1 -0
  97. package/dist/utils/logger.js +79 -0
  98. package/dist/utils/logger.js.map +1 -0
  99. package/dist/utils/retry.d.ts +43 -0
  100. package/dist/utils/retry.d.ts.map +1 -0
  101. package/dist/utils/retry.js +175 -0
  102. package/dist/utils/retry.js.map +1 -0
  103. package/docs/API.md +745 -0
  104. package/docs/ARCHITECTURE.md +307 -0
  105. package/docs/BAILEYS_FIXES.md +360 -0
  106. package/docs/FEATURES.md +532 -0
  107. package/docs/PROTOCOL.md +489 -0
  108. package/docs/RECOVERY.md +409 -0
  109. package/docs/SKDM.md +233 -0
  110. package/examples/ai-bot/index.ts +479 -0
  111. package/examples/ai-bot/package.json +17 -0
  112. package/examples/echo-bot/index.ts +171 -0
  113. package/examples/echo-bot/package.json +18 -0
  114. package/examples/group-bot/index.ts +557 -0
  115. package/examples/group-bot/package.json +17 -0
  116. package/examples/rest-gateway/index.ts +499 -0
  117. package/examples/rest-gateway/package.json +19 -0
  118. package/package.json +75 -0
  119. package/src/advanced/features.ts +817 -0
  120. package/src/auth/auth-state.ts +342 -0
  121. package/src/auth/pairing-code.ts +246 -0
  122. package/src/auth/qr-code.ts +191 -0
  123. package/src/calls/handler.ts +153 -0
  124. package/src/core/binary.ts +464 -0
  125. package/src/core/crypto.ts +189 -0
  126. package/src/core/noise.ts +406 -0
  127. package/src/events/emitter.ts +88 -0
  128. package/src/features/index.ts +921 -0
  129. package/src/fixes/index.ts +882 -0
  130. package/src/groups/management.ts +497 -0
  131. package/src/index.ts +274 -0
  132. package/src/messages/media.ts +372 -0
  133. package/src/messages/message-encoder.ts +520 -0
  134. package/src/messages/send.ts +521 -0
  135. package/src/recovery/index.ts +704 -0
  136. package/src/skdm/index.ts +693 -0
  137. package/src/socket/hybrid.ts +414 -0
  138. package/src/socket/wa-socket.ts +1347 -0
  139. package/src/socket/ws-socket.ts +355 -0
  140. package/src/types/index.ts +457 -0
  141. package/src/utils/jid.ts +156 -0
  142. package/src/utils/logger.ts +84 -0
  143. package/src/utils/retry.ts +205 -0
  144. package/tsconfig.json +30 -0
@@ -0,0 +1,704 @@
1
+ /**
2
+ * Waliwa - Multi-Branch SKDM Recovery System
3
+ *
4
+ * Unlike basic reconnect yang cuma coba 1 path, ini implementasi full multi-branch
5
+ * Session Key & Device Management (SKDM) recovery system.
6
+ *
7
+ * Saat disconnect terjadi, sistem mencoba multiple recovery strategies in order:
8
+ *
9
+ * ┌──────────────────────────┬───────────────────────────────────────────────────────────────┐
10
+ * │ Disconnect Reason │ Recovery Branches (in order) │
11
+ * ├──────────────────────────┼───────────────────────────────────────────────────────────────┤
12
+ * │ Connection lost │ Immediate retry → Stream reconnect → Backoff → Key refresh → │
13
+ * │ │ Pre-key fetch → Full re-auth │
14
+ * ├──────────────────────────┼───────────────────────────────────────────────────────────────┤
15
+ * │ Rate limited (429) │ Wait cooldown (5min) → Backoff retry │
16
+ * ├──────────────────────────┼───────────────────────────────────────────────────────────────┤
17
+ * │ Restart required (428) │ Update app version → Key refresh → Full re-auth QR │
18
+ * ├──────────────────────────┼───────────────────────────────────────────────────────┤
19
+ * │ Logged out (401/403) │ Key refresh → No recovery (fatal) │
20
+ * ├──────────────────────────┼───────────────────────────────────────────────────────────────┤
21
+ * │ Forbidden (511) │ Immediate retry → Key refresh → No recovery │
22
+ * ├──────────────────────────┼───────────────────────────────────────────────────────────────┤
23
+ * │ Multi-device mismatch │ Key refresh → Full re-auth QR → No recovery │
24
+ * │ (409) │ │
25
+ * ├──────────────────────────┼───────────────────────────────────────────────────────────────┤
26
+ * │ Bad session │ Key refresh → Pre-key fetch → Full re-auth QR │
27
+ * ├──────────────────────────┼───────────────────────────────────────────────────────────────┤
28
+ * │ Connection replaced (440)│ Key refresh → Full re-auth QR │
29
+ * └──────────────────────────┴───────────────────────────────────────────────────────────────┘
30
+ *
31
+ * Each branch punya retry limit sendiri (default 3).
32
+ * Total attempts across all branches di-cap (default 20).
33
+ */
34
+
35
+ import { EventEmitter } from 'events';
36
+ import type { WALogger } from '../types/index.js';
37
+ import { SilentLogger } from '../utils/logger.js';
38
+ import { sleep } from '../utils/retry.js';
39
+
40
+ // ============== Disconnect Reasons ==============
41
+
42
+ export type DisconnectReason =
43
+ | 'connection_lost' // 1006, 1001, atau no code
44
+ | 'rate_limited' // 429
45
+ | 'restart_required' // 428
46
+ | 'logged_out' // 401, 403
47
+ | 'forbidden' // 511
48
+ | 'multi_device_mismatch' // 409
49
+ | 'bad_session' // generic
50
+ | 'connection_replaced' // 440
51
+ | 'normal' // 1000 - intentional close, no recovery
52
+ | 'unknown';
53
+
54
+ /**
55
+ * Map WhatsApp disconnect code ke DisconnectReason
56
+ */
57
+ export function mapCodeToReason(code: number | undefined, reason?: string): DisconnectReason {
58
+ if (reason === 'normal' || code === 1000) return 'normal';
59
+ if (code === 429) return 'rate_limited';
60
+ if (code === 428) return 'restart_required';
61
+ if (code === 401 || code === 403) return 'logged_out';
62
+ if (code === 511) return 'forbidden';
63
+ if (code === 409) return 'multi_device_mismatch';
64
+ if (code === 440) return 'connection_replaced';
65
+ if (code === 1006 || code === 1001 || code === undefined || code === 0) return 'connection_lost';
66
+
67
+ // Check reason string untuk more context
68
+ if (reason) {
69
+ const r = reason.toLowerCase();
70
+ if (r.includes('logged out') || r.includes('unauthorized')) return 'logged_out';
71
+ if (r.includes('rate limit') || r.includes('too many')) return 'rate_limited';
72
+ if (r.includes('restart') || r.includes('update')) return 'restart_required';
73
+ if (r.includes('forbidden')) return 'forbidden';
74
+ if (r.includes('multi-device') || r.includes('mismatch')) return 'multi_device_mismatch';
75
+ if (r.includes('replaced') || r.includes('another device')) return 'connection_replaced';
76
+ if (r.includes('bad session') || r.includes('invalid session')) return 'bad_session';
77
+ }
78
+
79
+ return 'unknown';
80
+ }
81
+
82
+ // ============== Recovery Actions ==============
83
+
84
+ export type RecoveryAction =
85
+ | 'immediate_retry'
86
+ | 'stream_reconnect'
87
+ | 'backoff_retry'
88
+ | 'wait_cooldown'
89
+ | 'update_app_version'
90
+ | 'key_refresh'
91
+ | 'prekey_fetch'
92
+ | 'full_reauth_qr'
93
+ | 'no_recovery';
94
+
95
+ /**
96
+ * Recovery context - passed ke setiap action handler
97
+ */
98
+ export interface RecoveryContext {
99
+ reason: DisconnectReason;
100
+ code?: number;
101
+ errorMessage?: string;
102
+ attempt: number;
103
+ totalAttempts: number;
104
+ branchIndex: number;
105
+ lastAttemptTime: number;
106
+ startTime: number;
107
+ history: Array<{
108
+ action: RecoveryAction;
109
+ success: boolean;
110
+ duration: number;
111
+ error?: string;
112
+ timestamp: number;
113
+ }>;
114
+ }
115
+
116
+ /**
117
+ * Recovery branch - ordered list of actions untuk a reason
118
+ */
119
+ export interface RecoveryBranch {
120
+ reason: DisconnectReason;
121
+ actions: RecoveryAction[];
122
+ maxAttempts?: number;
123
+ fatal?: boolean;
124
+ }
125
+
126
+ // ============== Recovery Branch Definitions ==============
127
+
128
+ export const DEFAULT_RECOVERY_BRANCHES: RecoveryBranch[] = [
129
+ {
130
+ reason: 'connection_lost',
131
+ actions: [
132
+ 'immediate_retry',
133
+ 'stream_reconnect',
134
+ 'backoff_retry',
135
+ 'key_refresh',
136
+ 'prekey_fetch',
137
+ 'full_reauth_qr'
138
+ ],
139
+ maxAttempts: 3
140
+ },
141
+ {
142
+ reason: 'rate_limited',
143
+ actions: [
144
+ 'wait_cooldown',
145
+ 'backoff_retry'
146
+ ],
147
+ maxAttempts: 3
148
+ },
149
+ {
150
+ reason: 'restart_required',
151
+ actions: [
152
+ 'update_app_version',
153
+ 'key_refresh',
154
+ 'full_reauth_qr'
155
+ ],
156
+ maxAttempts: 3
157
+ },
158
+ {
159
+ reason: 'logged_out',
160
+ actions: [
161
+ 'key_refresh'
162
+ ],
163
+ maxAttempts: 1,
164
+ fatal: true
165
+ },
166
+ {
167
+ reason: 'forbidden',
168
+ actions: [
169
+ 'immediate_retry',
170
+ 'key_refresh'
171
+ ],
172
+ maxAttempts: 3
173
+ },
174
+ {
175
+ reason: 'multi_device_mismatch',
176
+ actions: [
177
+ 'key_refresh',
178
+ 'full_reauth_qr'
179
+ ],
180
+ maxAttempts: 3
181
+ },
182
+ {
183
+ reason: 'bad_session',
184
+ actions: [
185
+ 'key_refresh',
186
+ 'prekey_fetch',
187
+ 'full_reauth_qr'
188
+ ],
189
+ maxAttempts: 3
190
+ },
191
+ {
192
+ reason: 'connection_replaced',
193
+ actions: [
194
+ 'key_refresh',
195
+ 'full_reauth_qr'
196
+ ],
197
+ maxAttempts: 3
198
+ },
199
+ {
200
+ reason: 'unknown',
201
+ actions: [
202
+ 'backoff_retry',
203
+ 'key_refresh',
204
+ 'full_reauth_qr'
205
+ ],
206
+ maxAttempts: 3
207
+ }
208
+ ];
209
+
210
+ // ============== Recovery Config ==============
211
+
212
+ export interface RecoveryConfig {
213
+ enabled?: boolean;
214
+ maxAttemptsPerBranch?: number;
215
+ maxTotalAttempts?: number;
216
+ initialDelayMs?: number;
217
+ maxDelayMs?: number;
218
+ rateLimitedDelayMs?: number;
219
+ branches?: RecoveryBranch[];
220
+ logger?: WALogger;
221
+ actionHandlers?: Partial<Record<RecoveryAction, (ctx: RecoveryContext) => Promise<boolean>>>;
222
+ }
223
+
224
+ export const DEFAULT_RECOVERY_CONFIG: Required<Omit<RecoveryConfig, 'logger' | 'branches' | 'actionHandlers'>> = {
225
+ enabled: true,
226
+ maxAttemptsPerBranch: 3,
227
+ maxTotalAttempts: 20,
228
+ initialDelayMs: 1000,
229
+ maxDelayMs: 60000,
230
+ rateLimitedDelayMs: 300000
231
+ };
232
+
233
+ // ============== Recovery Manager ==============
234
+
235
+ export class RecoveryManager extends EventEmitter {
236
+ private config: Required<Omit<RecoveryConfig, 'logger' | 'branches' | 'actionHandlers'>>;
237
+ private branches: RecoveryBranch[];
238
+ private logger: WALogger;
239
+ private actionHandlers: Partial<Record<RecoveryAction, (ctx: RecoveryContext) => Promise<boolean>>>;
240
+ private defaultHandlers: Record<RecoveryAction, (ctx: RecoveryContext) => Promise<boolean>>;
241
+
242
+ private isRecovering: boolean = false;
243
+ private currentContext: RecoveryContext | null = null;
244
+ private recoveryStats: {
245
+ totalRecoveries: number;
246
+ successfulRecoveries: number;
247
+ failedRecoveries: number;
248
+ byReason: Map<DisconnectReason, { success: number; failure: number }>;
249
+ };
250
+
251
+ // Action callbacks - di-set oleh WASocket saat integrate
252
+ public connect: () => Promise<boolean> = async () => false;
253
+ public streamReconnect: () => Promise<boolean> = async () => false;
254
+ public refreshKeys: () => Promise<void> = async () => {};
255
+ public fetchPreKeys: () => Promise<void> = async () => {};
256
+ public startFullReauth: () => Promise<void> = async () => {};
257
+ public updateAppVersion: () => Promise<void> = async () => {};
258
+
259
+ constructor(config: RecoveryConfig = {}) {
260
+ super();
261
+ this.config = { ...DEFAULT_RECOVERY_CONFIG, ...config } as any;
262
+ this.branches = config.branches ?? DEFAULT_RECOVERY_BRANCHES;
263
+ this.logger = config.logger ?? new SilentLogger();
264
+ this.actionHandlers = config.actionHandlers ?? {};
265
+
266
+ this.defaultHandlers = this.createDefaultHandlers();
267
+ this.recoveryStats = {
268
+ totalRecoveries: 0,
269
+ successfulRecoveries: 0,
270
+ failedRecoveries: 0,
271
+ byReason: new Map()
272
+ };
273
+ }
274
+
275
+ // ============== Public API ==============
276
+
277
+ async recover(reason: DisconnectReason, code?: number, errorMessage?: string): Promise<boolean> {
278
+ if (!this.config.enabled || reason === 'normal') {
279
+ this.logger.debug(`Recovery skipped (enabled=${this.config.enabled}, reason=${reason})`);
280
+ return false;
281
+ }
282
+
283
+ if (this.isRecovering) {
284
+ this.logger.warn('Recovery already in progress, ignoring new recovery request');
285
+ return false;
286
+ }
287
+
288
+ this.isRecovering = true;
289
+ this.recoveryStats.totalRecoveries++;
290
+
291
+ const ctx: RecoveryContext = {
292
+ reason,
293
+ code,
294
+ errorMessage,
295
+ attempt: 0,
296
+ totalAttempts: 0,
297
+ branchIndex: 0,
298
+ lastAttemptTime: Date.now(),
299
+ startTime: Date.now(),
300
+ history: []
301
+ };
302
+ this.currentContext = ctx;
303
+
304
+ this.emit('recovery:start', { reason, code, message: errorMessage });
305
+ this.logger.info(`Starting multi-branch recovery for reason: ${reason}${code ? ` (code ${code})` : ''}`);
306
+
307
+ const branch = this.branches.find(b => b.reason === reason);
308
+ if (!branch) {
309
+ this.logger.error(`No recovery branch found for reason: ${reason}`);
310
+ await this.failRecovery(ctx, false);
311
+ return false;
312
+ }
313
+
314
+ if (branch.fatal) {
315
+ this.logger.error(`Fatal disconnect reason: ${reason} - no recovery possible`);
316
+ for (const action of branch.actions) {
317
+ await this.runAction(action, ctx);
318
+ }
319
+ await this.failRecovery(ctx, true);
320
+ return false;
321
+ }
322
+
323
+ const maxPerBranch = branch.maxAttempts ?? this.config.maxAttemptsPerBranch;
324
+ const branchActions = branch.actions;
325
+
326
+ this.emit('recovery:branch:start', {
327
+ reason,
328
+ branchIndex: 0,
329
+ actions: branchActions
330
+ });
331
+ this.logger.info(`Recovery branch for ${reason}: ${branchActions.join(' → ')}`);
332
+
333
+ for (let actionIdx = 0; actionIdx < branchActions.length; actionIdx++) {
334
+ const action = branchActions[actionIdx];
335
+ let branchSuccess = false;
336
+
337
+ for (let attempt = 1; attempt <= maxPerBranch; attempt++) {
338
+ if (ctx.totalAttempts >= this.config.maxTotalAttempts) {
339
+ this.logger.error(`Total max attempts (${this.config.maxTotalAttempts}) reached`);
340
+ await this.failRecovery(ctx, false);
341
+ return false;
342
+ }
343
+
344
+ ctx.attempt = attempt;
345
+ ctx.totalAttempts++;
346
+
347
+ const progress = (ctx.totalAttempts / this.config.maxTotalAttempts) * 100;
348
+ this.emit('recovery:progress', {
349
+ currentAction: action,
350
+ totalAttempts: ctx.totalAttempts,
351
+ maxTotalAttempts: this.config.maxTotalAttempts,
352
+ progress
353
+ });
354
+
355
+ const success = await this.runAction(action, ctx);
356
+
357
+ if (success) {
358
+ branchSuccess = true;
359
+ break;
360
+ }
361
+
362
+ if (attempt < maxPerBranch) {
363
+ const delay = this.calculateDelay(action, attempt);
364
+ this.logger.debug(`Waiting ${delay}ms before next attempt`);
365
+ await sleep(delay);
366
+ }
367
+ }
368
+
369
+ if (branchSuccess) {
370
+ await this.succeedRecovery(ctx);
371
+ return true;
372
+ }
373
+
374
+ if (actionIdx < branchActions.length - 1) {
375
+ this.logger.info(`Action ${action} exhausted, moving to next action: ${branchActions[actionIdx + 1]}`);
376
+ }
377
+ }
378
+
379
+ this.emit('recovery:branch:exhausted', {
380
+ reason,
381
+ branchIndex: 0,
382
+ attempts: ctx.totalAttempts
383
+ });
384
+ await this.failRecovery(ctx, false);
385
+ return false;
386
+ }
387
+
388
+ private async runAction(action: RecoveryAction, ctx: RecoveryContext): Promise<boolean> {
389
+ const startTime = Date.now();
390
+ this.emit('recovery:action:start', {
391
+ action,
392
+ attempt: ctx.attempt,
393
+ branchIndex: ctx.branchIndex
394
+ });
395
+ this.logger.info(`Recovery action: ${action} (attempt ${ctx.attempt})`);
396
+
397
+ try {
398
+ const handler = this.actionHandlers[action] ?? this.defaultHandlers[action];
399
+ if (!handler) {
400
+ throw new Error(`No handler for action: ${action}`);
401
+ }
402
+
403
+ const success = await handler(ctx);
404
+ const duration = Date.now() - startTime;
405
+
406
+ ctx.history.push({
407
+ action,
408
+ success,
409
+ duration,
410
+ timestamp: Date.now()
411
+ });
412
+
413
+ if (success) {
414
+ this.emit('recovery:action:success', { action, attempt: ctx.attempt, duration });
415
+ this.logger.info(`Recovery action ${action} succeeded in ${duration}ms`);
416
+ } else {
417
+ this.emit('recovery:action:failed', {
418
+ action,
419
+ attempt: ctx.attempt,
420
+ error: 'Action returned false',
421
+ duration
422
+ });
423
+ this.logger.warn(`Recovery action ${action} failed (attempt ${ctx.attempt})`);
424
+ }
425
+
426
+ return success;
427
+ } catch (err: any) {
428
+ const duration = Date.now() - startTime;
429
+ ctx.history.push({
430
+ action,
431
+ success: false,
432
+ duration,
433
+ error: err.message,
434
+ timestamp: Date.now()
435
+ });
436
+ this.emit('recovery:action:failed', {
437
+ action,
438
+ attempt: ctx.attempt,
439
+ error: err.message,
440
+ duration
441
+ });
442
+ this.logger.error(`Recovery action ${action} threw error: ${err.message}`);
443
+ return false;
444
+ }
445
+ }
446
+
447
+ private calculateDelay(action: RecoveryAction, attempt: number): number {
448
+ switch (action) {
449
+ case 'wait_cooldown':
450
+ return this.config.rateLimitedDelayMs;
451
+ case 'immediate_retry':
452
+ return 0;
453
+ case 'stream_reconnect':
454
+ return Math.min(this.config.initialDelayMs, 500);
455
+ case 'backoff_retry':
456
+ case 'key_refresh':
457
+ case 'prekey_fetch':
458
+ case 'full_reauth_qr':
459
+ case 'update_app_version':
460
+ return Math.min(
461
+ this.config.initialDelayMs * Math.pow(2, attempt - 1),
462
+ this.config.maxDelayMs
463
+ );
464
+ default:
465
+ return this.config.initialDelayMs;
466
+ }
467
+ }
468
+
469
+ // ============== Default Action Handlers ==============
470
+
471
+ private createDefaultHandlers(): Record<RecoveryAction, (ctx: RecoveryContext) => Promise<boolean>> {
472
+ return {
473
+ immediate_retry: async (ctx) => {
474
+ this.logger.info('Immediate retry: attempting reconnect...');
475
+ try {
476
+ return await this.connect();
477
+ } catch (err: any) {
478
+ this.logger.error(`Immediate retry failed: ${err.message}`);
479
+ return false;
480
+ }
481
+ },
482
+
483
+ stream_reconnect: async (ctx) => {
484
+ this.logger.info('Stream reconnect: attempting to resume stream state...');
485
+ try {
486
+ return await this.streamReconnect();
487
+ } catch (err: any) {
488
+ this.logger.error(`Stream reconnect failed: ${err.message}`);
489
+ return false;
490
+ }
491
+ },
492
+
493
+ backoff_retry: async (ctx) => {
494
+ const delay = Math.min(
495
+ this.config.initialDelayMs * Math.pow(2, ctx.attempt - 1),
496
+ this.config.maxDelayMs
497
+ );
498
+ this.logger.info(`Backoff retry: waiting ${delay}ms before retry...`);
499
+ await sleep(delay);
500
+ try {
501
+ return await this.connect();
502
+ } catch (err: any) {
503
+ this.logger.error(`Backoff retry failed: ${err.message}`);
504
+ return false;
505
+ }
506
+ },
507
+
508
+ wait_cooldown: async (ctx) => {
509
+ this.logger.info(`Rate limited: waiting ${this.config.rateLimitedDelayMs}ms cooldown...`);
510
+ await sleep(this.config.rateLimitedDelayMs);
511
+ try {
512
+ return await this.connect();
513
+ } catch (err: any) {
514
+ this.logger.error(`Cooldown retry failed: ${err.message}`);
515
+ return false;
516
+ }
517
+ },
518
+
519
+ update_app_version: async (ctx) => {
520
+ this.logger.info('Updating app version (browser identification)...');
521
+ try {
522
+ await this.updateAppVersion();
523
+ return await this.connect();
524
+ } catch (err: any) {
525
+ this.logger.error(`Update app version failed: ${err.message}`);
526
+ return false;
527
+ }
528
+ },
529
+
530
+ key_refresh: async (ctx) => {
531
+ this.logger.info('Refreshing keys (SKDM)...');
532
+ try {
533
+ await this.refreshKeys();
534
+ return await this.connect();
535
+ } catch (err: any) {
536
+ this.logger.error(`Key refresh failed: ${err.message}`);
537
+ return false;
538
+ }
539
+ },
540
+
541
+ prekey_fetch: async (ctx) => {
542
+ this.logger.info('Fetching pre-keys...');
543
+ try {
544
+ await this.fetchPreKeys();
545
+ return await this.connect();
546
+ } catch (err: any) {
547
+ this.logger.error(`Pre-key fetch failed: ${err.message}`);
548
+ return false;
549
+ }
550
+ },
551
+
552
+ full_reauth_qr: async (ctx) => {
553
+ this.logger.info('Starting full re-authentication (new QR)...');
554
+ try {
555
+ await this.startFullReauth();
556
+ await sleep(5000);
557
+ return await this.connect();
558
+ } catch (err: any) {
559
+ this.logger.error(`Full re-auth failed: ${err.message}`);
560
+ return false;
561
+ }
562
+ },
563
+
564
+ no_recovery: async (ctx) => {
565
+ this.logger.error('No recovery possible for this disconnect reason');
566
+ return false;
567
+ }
568
+ };
569
+ }
570
+
571
+ // ============== Success/Failure Handling ==============
572
+
573
+ private async succeedRecovery(ctx: RecoveryContext): Promise<void> {
574
+ const duration = Date.now() - ctx.startTime;
575
+ this.isRecovering = false;
576
+ this.currentContext = null;
577
+ this.recoveryStats.successfulRecoveries++;
578
+ this.recordReasonResult(ctx.reason, true);
579
+
580
+ this.emit('recovery:success', {
581
+ reason: ctx.reason,
582
+ totalAttempts: ctx.totalAttempts,
583
+ duration
584
+ });
585
+ this.logger.info(`Recovery succeeded after ${ctx.totalAttempts} attempts in ${duration}ms`);
586
+ }
587
+
588
+ private async failRecovery(ctx: RecoveryContext, fatal: boolean): Promise<void> {
589
+ const duration = Date.now() - ctx.startTime;
590
+ this.isRecovering = false;
591
+ this.currentContext = null;
592
+ this.recoveryStats.failedRecoveries++;
593
+ this.recordReasonResult(ctx.reason, false);
594
+
595
+ this.emit('recovery:failed', {
596
+ reason: ctx.reason,
597
+ totalAttempts: ctx.totalAttempts,
598
+ duration,
599
+ fatal
600
+ });
601
+ this.logger.error(`Recovery failed after ${ctx.totalAttempts} attempts in ${duration}ms (fatal: ${fatal})`);
602
+ }
603
+
604
+ private recordReasonResult(reason: DisconnectReason, success: boolean): void {
605
+ let stats = this.recoveryStats.byReason.get(reason);
606
+ if (!stats) {
607
+ stats = { success: 0, failure: 0 };
608
+ this.recoveryStats.byReason.set(reason, stats);
609
+ }
610
+ if (success) stats.success++;
611
+ else stats.failure++;
612
+ }
613
+
614
+ // ============== Public Helpers ==============
615
+
616
+ isCurrentlyRecovering(): boolean {
617
+ return this.isRecovering;
618
+ }
619
+
620
+ getCurrentContext(): RecoveryContext | null {
621
+ return this.currentContext;
622
+ }
623
+
624
+ getStats(): {
625
+ totalRecoveries: number;
626
+ successfulRecoveries: number;
627
+ failedRecoveries: number;
628
+ successRate: number;
629
+ byReason: Record<string, { success: number; failure: number }>;
630
+ } {
631
+ const byReasonObj: Record<string, { success: number; failure: number }> = {};
632
+ for (const [reason, stats] of this.recoveryStats.byReason.entries()) {
633
+ byReasonObj[reason] = stats;
634
+ }
635
+ return {
636
+ totalRecoveries: this.recoveryStats.totalRecoveries,
637
+ successfulRecoveries: this.recoveryStats.successfulRecoveries,
638
+ failedRecoveries: this.recoveryStats.failedRecoveries,
639
+ successRate: this.recoveryStats.totalRecoveries === 0
640
+ ? 0
641
+ : this.recoveryStats.successfulRecoveries / this.recoveryStats.totalRecoveries,
642
+ byReason: byReasonObj
643
+ };
644
+ }
645
+
646
+ getBranchForReason(reason: DisconnectReason): RecoveryBranch | undefined {
647
+ return this.branches.find(b => b.reason === reason);
648
+ }
649
+
650
+ cancel(): void {
651
+ if (!this.isRecovering) return;
652
+ this.logger.warn('Recovery cancelled by user');
653
+ this.isRecovering = false;
654
+ this.currentContext = null;
655
+ this.emit('recovery:failed', {
656
+ reason: 'unknown',
657
+ totalAttempts: 0,
658
+ duration: 0,
659
+ fatal: false
660
+ });
661
+ }
662
+
663
+ resetStats(): void {
664
+ this.recoveryStats = {
665
+ totalRecoveries: 0,
666
+ successfulRecoveries: 0,
667
+ failedRecoveries: 0,
668
+ byReason: new Map()
669
+ };
670
+ }
671
+ }
672
+
673
+ // ============== Helpers ==============
674
+
675
+ export function getActionDescription(action: RecoveryAction): string {
676
+ const descriptions: Record<RecoveryAction, string> = {
677
+ immediate_retry: 'Coba reconnect langsung tanpa delay',
678
+ stream_reconnect: 'Resume koneksi dari stream state yang tersimpan',
679
+ backoff_retry: 'Reconnect dengan exponential backoff',
680
+ wait_cooldown: 'Tunggu cooldown 5 menit (untuk rate limit)',
681
+ update_app_version: 'Update identifikasi browser/app version',
682
+ key_refresh: 'Refresh Signal keys via SKDM',
683
+ prekey_fetch: 'Fetch pre-keys baru dari server',
684
+ full_reauth_qr: 'Full re-authentication dengan QR code baru',
685
+ no_recovery: 'Tidak ada recovery yang possible (fatal)'
686
+ };
687
+ return descriptions[action] || action;
688
+ }
689
+
690
+ export function getReasonDescription(reason: DisconnectReason): string {
691
+ const descriptions: Record<DisconnectReason, string> = {
692
+ connection_lost: 'Koneksi terputus tanpa alasan spesifik',
693
+ rate_limited: 'Rate limited oleh server (HTTP 429)',
694
+ restart_required: 'Server memerlukan restart/update (HTTP 428)',
695
+ logged_out: 'Logged out dari WhatsApp (HTTP 401/403) - fatal',
696
+ forbidden: 'Akses forbidden (HTTP 511)',
697
+ multi_device_mismatch: 'Multi-device state mismatch (HTTP 409)',
698
+ bad_session: 'Session tidak valid',
699
+ connection_replaced: 'Koneksi diganti dengan device lain (HTTP 440)',
700
+ normal: 'Normal close (intentional)',
701
+ unknown: 'Unknown disconnect reason'
702
+ };
703
+ return descriptions[reason] || reason;
704
+ }