@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,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
|
+
}
|