@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,1347 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Waliwa - Main WASocket interface
|
|
3
|
+
* Entry point untuk WhatsApp bot - gabungin semua components (WebSocket, Auth, Messages, Groups, Calls)
|
|
4
|
+
*
|
|
5
|
+
* API Baileys-style dengan RAM optimization:
|
|
6
|
+
* - Lazy initialization
|
|
7
|
+
* - Buffer pooling
|
|
8
|
+
* - Debounced writes
|
|
9
|
+
* - Event-driven (no polling overhead)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { WAWebSocket } from '../socket/ws-socket.js';
|
|
13
|
+
import { WAWebSocketOptions } from '../socket/ws-socket.js';
|
|
14
|
+
import { NoiseHandshake } from '../core/noise.js';
|
|
15
|
+
import { decodeBinaryNode, encodeBinaryNode, BinaryNode } from '../core/binary.js';
|
|
16
|
+
import { WAEventEmitter } from '../events/emitter.js';
|
|
17
|
+
import {
|
|
18
|
+
MessageSender,
|
|
19
|
+
MessageReceiver,
|
|
20
|
+
PendingMessageTracker,
|
|
21
|
+
buildReadReceiptNode,
|
|
22
|
+
buildChatPresenceNode
|
|
23
|
+
} from '../messages/send.js';
|
|
24
|
+
import {
|
|
25
|
+
MediaUploader,
|
|
26
|
+
MediaDownloader
|
|
27
|
+
} from '../messages/media.js';
|
|
28
|
+
import { GroupManager } from '../groups/management.js';
|
|
29
|
+
import { CallHandler } from '../calls/handler.js';
|
|
30
|
+
import { FileAuthState } from '../auth/auth-state.js';
|
|
31
|
+
import { generateQRCodeData, QRRefreshManager } from '../auth/qr-code.js';
|
|
32
|
+
import { generatePairingCode, normalizePhoneNumber } from '../auth/pairing-code.js';
|
|
33
|
+
import { jidToString } from '../utils/jid.js';
|
|
34
|
+
import { defaultLogger, SilentLogger } from '../utils/logger.js';
|
|
35
|
+
import { retry, RateLimiter } from '../utils/retry.js';
|
|
36
|
+
// SKDM imports
|
|
37
|
+
import { SKDM } from '../skdm/index.js';
|
|
38
|
+
// Fixes imports
|
|
39
|
+
import {
|
|
40
|
+
ConnectionHeartbeat,
|
|
41
|
+
IdempotencyCache,
|
|
42
|
+
GroupEventCoalescer,
|
|
43
|
+
HistorySyncManager,
|
|
44
|
+
DecryptionRetryQueue,
|
|
45
|
+
MessageQueue,
|
|
46
|
+
ChunkedMediaUploader,
|
|
47
|
+
SmarterReconnector
|
|
48
|
+
} from '../fixes/index.js';
|
|
49
|
+
// Features imports
|
|
50
|
+
import {
|
|
51
|
+
HealthMonitor,
|
|
52
|
+
WebhookManager,
|
|
53
|
+
CircuitBreaker,
|
|
54
|
+
RateLimitTracker,
|
|
55
|
+
MessagePersistence
|
|
56
|
+
} from '../features/index.js';
|
|
57
|
+
// Recovery imports
|
|
58
|
+
import {
|
|
59
|
+
RecoveryManager,
|
|
60
|
+
mapCodeToReason,
|
|
61
|
+
type RecoveryConfig,
|
|
62
|
+
type DisconnectReason
|
|
63
|
+
} from '../recovery/index.js';
|
|
64
|
+
import type {
|
|
65
|
+
WaliwaConfig,
|
|
66
|
+
ConnectionUpdate,
|
|
67
|
+
QRGenerationData,
|
|
68
|
+
PairingCodeResult,
|
|
69
|
+
MessageContent,
|
|
70
|
+
WAMessage,
|
|
71
|
+
WAMessageKey,
|
|
72
|
+
JIDLike,
|
|
73
|
+
SendMessageResult,
|
|
74
|
+
WAGroupMetadata,
|
|
75
|
+
GroupCreateOptions,
|
|
76
|
+
WACallEvent,
|
|
77
|
+
PresenceType,
|
|
78
|
+
MessageStatus,
|
|
79
|
+
AuthenticationCreds,
|
|
80
|
+
WAChat,
|
|
81
|
+
ContactRecord,
|
|
82
|
+
GroupParticipantUpdate,
|
|
83
|
+
WALogger,
|
|
84
|
+
MessageType,
|
|
85
|
+
MessageUpsertType,
|
|
86
|
+
AuthState
|
|
87
|
+
} from '../types/index.js';
|
|
88
|
+
|
|
89
|
+
// ============== WASocket ==============
|
|
90
|
+
|
|
91
|
+
export interface WASocket {
|
|
92
|
+
// Connection
|
|
93
|
+
/** Event emitter untuk semua WhatsApp events */
|
|
94
|
+
ev: WAEventEmitter;
|
|
95
|
+
/** Auth state */
|
|
96
|
+
authState: AuthState;
|
|
97
|
+
/** Logger */
|
|
98
|
+
user: { id: string; name?: string } | undefined;
|
|
99
|
+
|
|
100
|
+
// Auth methods
|
|
101
|
+
/** Logout dan clear session */
|
|
102
|
+
logout(): Promise<void>;
|
|
103
|
+
/** Refetch QR code */
|
|
104
|
+
refreshQR(): Promise<QRGenerationData>;
|
|
105
|
+
/** Request pairing code (Mobile API mode) */
|
|
106
|
+
requestPairingCode(phoneNumber: string): Promise<PairingCodeResult>;
|
|
107
|
+
|
|
108
|
+
// Message methods
|
|
109
|
+
sendMessage(jid: JIDLike, content: MessageContent, opts?: any): Promise<SendMessageResult>;
|
|
110
|
+
forwardMessage(to: JIDLike, message: WAMessage): Promise<SendMessageResult>;
|
|
111
|
+
deleteMessage(jid: JIDLike, key: WAMessageKey, opts?: { revokeEveryone?: boolean }): Promise<SendMessageResult>;
|
|
112
|
+
reactToMessage(jid: JIDLike, key: WAMessageKey, emoji: string): Promise<SendMessageResult>;
|
|
113
|
+
/** Send read receipt */
|
|
114
|
+
sendReadReceipt(jid: JIDLike, messageIds: string[], participant?: JIDLike): Promise<void>;
|
|
115
|
+
/** Send typing indicator */
|
|
116
|
+
sendPresenceUpdate(jid: JIDLike, presence: PresenceType): Promise<void>;
|
|
117
|
+
/** Update presence (online/offline) */
|
|
118
|
+
updatePresence(presence: 'available' | 'unavailable'): Promise<void>;
|
|
119
|
+
|
|
120
|
+
// Media
|
|
121
|
+
uploadMedia(media: Buffer | Uint8Array, mediaType: string, mimetype: string, fileName?: string): Promise<any>;
|
|
122
|
+
downloadMedia(url: string, mediaKey: Uint8Array, mediaType: string, mimetype: string, fileName?: string): Promise<any>;
|
|
123
|
+
|
|
124
|
+
// Group methods
|
|
125
|
+
groupCreate(opts: GroupCreateOptions): Promise<WAGroupMetadata>;
|
|
126
|
+
groupMetadata(jid: JIDLike): Promise<WAGroupMetadata>;
|
|
127
|
+
groupUpdateSubject(jid: JIDLike, subject: string): Promise<void>;
|
|
128
|
+
groupUpdateDescription(jid: JIDLike, desc: string): Promise<void>;
|
|
129
|
+
groupParticipantsUpdate(jid: JIDLike, participants: JIDLike[], action: 'add' | 'remove' | 'promote' | 'demote'): Promise<void>;
|
|
130
|
+
groupLeave(jid: JIDLike): Promise<void>;
|
|
131
|
+
groupInviteCode(jid: JIDLike): Promise<string>;
|
|
132
|
+
groupRevokeInvite(jid: JIDLike): Promise<string>;
|
|
133
|
+
groupJoinViaCode(code: string): Promise<JIDLike>;
|
|
134
|
+
groupSettingUpdate(jid: JIDLike, setting: 'restrict' | 'announce', value: boolean): Promise<void>;
|
|
135
|
+
|
|
136
|
+
// Call methods
|
|
137
|
+
rejectCall(callId: string, from: JIDLike): Promise<void>;
|
|
138
|
+
|
|
139
|
+
// Chat methods
|
|
140
|
+
chatModify(modification: any, jid: JIDLike): Promise<void>;
|
|
141
|
+
/** Mark chat sebagai read */
|
|
142
|
+
readChat(jid: JIDLike): Promise<void>;
|
|
143
|
+
/** Archive chat */
|
|
144
|
+
archiveChat(jid: JIDLike, archive: boolean): Promise<void>;
|
|
145
|
+
/** Mute chat */
|
|
146
|
+
muteChat(jid: JIDLike, durationMs: number): Promise<void>;
|
|
147
|
+
/** Pin chat */
|
|
148
|
+
pinChat(jid: JIDLike, durationMs: number): Promise<void>;
|
|
149
|
+
/** Delete chat */
|
|
150
|
+
deleteChat(jid: JIDLike): Promise<void>;
|
|
151
|
+
|
|
152
|
+
// Presence
|
|
153
|
+
presenceSubscribe(jids: JIDLike[]): Promise<void>;
|
|
154
|
+
|
|
155
|
+
// Connection management
|
|
156
|
+
end(newConnection?: boolean): Promise<void>;
|
|
157
|
+
/** Get connection state */
|
|
158
|
+
getConnectionState(): 'close' | 'open' | 'connecting';
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ============== WASocket Implementation ==============
|
|
162
|
+
|
|
163
|
+
export class WASocketImpl implements WASocket {
|
|
164
|
+
ev: WAEventEmitter;
|
|
165
|
+
authState: AuthState;
|
|
166
|
+
user: { id: string; name?: string } | undefined;
|
|
167
|
+
private logger: WALogger;
|
|
168
|
+
private ws: WAWebSocket | null = null;
|
|
169
|
+
private config: WaliwaConfig & {
|
|
170
|
+
wsURL: string;
|
|
171
|
+
browser: [string, string, string];
|
|
172
|
+
reconnectInterval: number;
|
|
173
|
+
maxReconnectAttempts: number;
|
|
174
|
+
syncFullHistory: boolean;
|
|
175
|
+
maxMsgRetryCount: number;
|
|
176
|
+
connectTimeoutMs: number;
|
|
177
|
+
keepAliveIntervalMs: number;
|
|
178
|
+
ramOptimizationLevel: 1 | 2 | 3;
|
|
179
|
+
markOnlineOnConnect: boolean;
|
|
180
|
+
printQRInTerminal: boolean;
|
|
181
|
+
mobile: boolean;
|
|
182
|
+
phoneNumber?: string;
|
|
183
|
+
};
|
|
184
|
+
private messageSender: MessageSender;
|
|
185
|
+
private messageReceiver: MessageReceiver;
|
|
186
|
+
private mediaUploader: MediaUploader;
|
|
187
|
+
private mediaDownloader: MediaDownloader;
|
|
188
|
+
private groupManager: GroupManager | null = null;
|
|
189
|
+
private callHandler: CallHandler | null = null;
|
|
190
|
+
private pendingTracker: PendingMessageTracker;
|
|
191
|
+
private iqHandlers: Map<string, (node: BinaryNode) => void> = new Map();
|
|
192
|
+
private qrRefreshManager: QRRefreshManager | null = null;
|
|
193
|
+
private rateLimiter: RateLimiter;
|
|
194
|
+
private connectionState: 'close' | 'open' | 'connecting' = 'close';
|
|
195
|
+
|
|
196
|
+
// SKDM
|
|
197
|
+
public skdm: SKDM | null = null;
|
|
198
|
+
|
|
199
|
+
// Fixes
|
|
200
|
+
private heartbeat: ConnectionHeartbeat | null = null;
|
|
201
|
+
private idempotencyCache: IdempotencyCache;
|
|
202
|
+
private groupEventCoalescer: GroupEventCoalescer | null = null;
|
|
203
|
+
private historySyncManager: HistorySyncManager;
|
|
204
|
+
private decryptionRetryQueue: DecryptionRetryQueue | null = null;
|
|
205
|
+
private messageQueue: MessageQueue;
|
|
206
|
+
private chunkedUploader: ChunkedMediaUploader;
|
|
207
|
+
private reconnector: SmarterReconnector | null = null;
|
|
208
|
+
|
|
209
|
+
// Features
|
|
210
|
+
public healthMonitor: HealthMonitor;
|
|
211
|
+
public webhookManager: WebhookManager;
|
|
212
|
+
public circuitBreaker: CircuitBreaker;
|
|
213
|
+
public rateLimitTracker: RateLimitTracker;
|
|
214
|
+
public messagePersistence: MessagePersistence;
|
|
215
|
+
|
|
216
|
+
// Recovery (Multi-Branch SKDM Recovery)
|
|
217
|
+
public recoveryManager: RecoveryManager;
|
|
218
|
+
|
|
219
|
+
// Auto-recovery timer
|
|
220
|
+
private reconnectTimer: NodeJS.Timeout | null = null;
|
|
221
|
+
private reconnectAttempts: number = 0;
|
|
222
|
+
|
|
223
|
+
constructor(config: WaliwaConfig = {}, authState: AuthState) {
|
|
224
|
+
this.ev = new WAEventEmitter();
|
|
225
|
+
this.authState = authState;
|
|
226
|
+
this.logger = config.logger ?? defaultLogger;
|
|
227
|
+
|
|
228
|
+
// Default config
|
|
229
|
+
this.config = {
|
|
230
|
+
authState,
|
|
231
|
+
logger: this.logger,
|
|
232
|
+
printQRInTerminal: config.printQRInTerminal ?? false,
|
|
233
|
+
mobile: config.mobile ?? false,
|
|
234
|
+
phoneNumber: config.phoneNumber,
|
|
235
|
+
browser: config.browser ?? ['Waliwa', 'WA', '1.0.0'],
|
|
236
|
+
reconnectInterval: config.reconnectInterval ?? 2000,
|
|
237
|
+
maxReconnectAttempts: config.maxReconnectAttempts ?? 10,
|
|
238
|
+
syncFullHistory: config.syncFullHistory ?? false,
|
|
239
|
+
maxMsgRetryCount: config.maxMsgRetryCount ?? 5,
|
|
240
|
+
wsURL: config.wsURL ?? 'wss://web.whatsapp.com/ws/chat',
|
|
241
|
+
connectTimeoutMs: config.connectTimeoutMs ?? 20000,
|
|
242
|
+
keepAliveIntervalMs: config.keepAliveIntervalMs ?? 20000,
|
|
243
|
+
ramOptimizationLevel: config.ramOptimizationLevel ?? 2,
|
|
244
|
+
markOnlineOnConnect: config.markOnlineOnConnect ?? true,
|
|
245
|
+
fetchAgent: config.fetchAgent
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
this.pendingTracker = new PendingMessageTracker();
|
|
249
|
+
this.messageSender = new MessageSender({
|
|
250
|
+
send: (data) => this.send(data),
|
|
251
|
+
logger: this.logger,
|
|
252
|
+
tracker: this.pendingTracker
|
|
253
|
+
});
|
|
254
|
+
this.messageReceiver = new MessageReceiver({
|
|
255
|
+
logger: this.logger,
|
|
256
|
+
onMessage: (messages, type) => {
|
|
257
|
+
this.ev.emit('message.upsert', { messages, type });
|
|
258
|
+
},
|
|
259
|
+
onStatusUpdate: (key, status) => {
|
|
260
|
+
this.ev.emit('message.update', {
|
|
261
|
+
messages: [{ key, status } as any],
|
|
262
|
+
keys: [key]
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
this.mediaUploader = new MediaUploader({
|
|
267
|
+
logger: this.logger,
|
|
268
|
+
httpAgent: this.config.fetchAgent
|
|
269
|
+
});
|
|
270
|
+
this.mediaDownloader = new MediaDownloader({
|
|
271
|
+
logger: this.logger,
|
|
272
|
+
httpAgent: this.config.fetchAgent
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
// Rate limiter: 20 messages/sec
|
|
276
|
+
this.rateLimiter = new RateLimiter(20);
|
|
277
|
+
|
|
278
|
+
// Initialize fixes (Baileys issues)
|
|
279
|
+
this.idempotencyCache = new IdempotencyCache(10000, 24 * 60 * 60 * 1000, this.logger);
|
|
280
|
+
this.historySyncManager = new HistorySyncManager(this.logger);
|
|
281
|
+
this.messageQueue = new MessageQueue(10, this.logger);
|
|
282
|
+
this.chunkedUploader = new ChunkedMediaUploader({ logger: this.logger });
|
|
283
|
+
|
|
284
|
+
// Initialize features
|
|
285
|
+
this.healthMonitor = new HealthMonitor({ logger: this.logger });
|
|
286
|
+
this.webhookManager = new WebhookManager({ logger: this.logger });
|
|
287
|
+
this.circuitBreaker = new CircuitBreaker({ logger: this.logger });
|
|
288
|
+
this.rateLimitTracker = new RateLimitTracker({ logger: this.logger });
|
|
289
|
+
this.messagePersistence = new MessagePersistence({ logger: this.logger });
|
|
290
|
+
|
|
291
|
+
// Initialize SKDM (Store Key Data Manager)
|
|
292
|
+
this.skdm = new SKDM(this.authState.creds, this.authState.keys, {
|
|
293
|
+
storageFolder: './auth',
|
|
294
|
+
logger: this.logger,
|
|
295
|
+
...config
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// Initialize Multi-Branch SKDM Recovery system
|
|
299
|
+
const recoveryConfig: RecoveryConfig = (config as any).recovery ?? {};
|
|
300
|
+
this.recoveryManager = new RecoveryManager({
|
|
301
|
+
enabled: recoveryConfig.enabled ?? true,
|
|
302
|
+
maxAttemptsPerBranch: recoveryConfig.maxAttemptsPerBranch ?? 3,
|
|
303
|
+
maxTotalAttempts: recoveryConfig.maxTotalAttempts ?? 20,
|
|
304
|
+
initialDelayMs: recoveryConfig.initialDelayMs ?? 1000,
|
|
305
|
+
maxDelayMs: recoveryConfig.maxDelayMs ?? 60000,
|
|
306
|
+
rateLimitedDelayMs: recoveryConfig.rateLimitedDelayMs ?? 300000,
|
|
307
|
+
logger: this.logger
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
// Bind recovery action handlers ke WASocket internals
|
|
311
|
+
this.setupRecoveryHandlers();
|
|
312
|
+
|
|
313
|
+
// Listen ke recovery events untuk forwarding ke main event emitter
|
|
314
|
+
this.recoveryManager.on('recovery:start', (info) => {
|
|
315
|
+
this.logger.info(`Recovery started: ${info.reason}`);
|
|
316
|
+
this.ev.emit('connection.update', {
|
|
317
|
+
connection: 'connecting',
|
|
318
|
+
lastDisconnect: { error: undefined, output: { code: info.code, reason: info.message } }
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
this.recoveryManager.on('recovery:success', (info) => {
|
|
323
|
+
this.logger.info(`Recovery succeeded: ${info.reason} (${info.totalAttempts} attempts)`);
|
|
324
|
+
this.healthMonitor.recordReconnect();
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
this.recoveryManager.on('recovery:failed', (info) => {
|
|
328
|
+
this.logger.error(`Recovery failed: ${info.reason} (fatal: ${info.fatal})`);
|
|
329
|
+
if (info.fatal) {
|
|
330
|
+
this.ev.emit('connection.update', {
|
|
331
|
+
connection: 'close',
|
|
332
|
+
lastDisconnect: {
|
|
333
|
+
error: new Error(`Fatal disconnect: ${info.reason}`),
|
|
334
|
+
output: { code: 0, reason: info.reason }
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Setup recovery handlers - bind ke internal WASocket methods
|
|
343
|
+
*/
|
|
344
|
+
private setupRecoveryHandlers(): void {
|
|
345
|
+
// Connect action
|
|
346
|
+
this.recoveryManager.connect = async (): Promise<boolean> => {
|
|
347
|
+
try {
|
|
348
|
+
if (this.ws) {
|
|
349
|
+
this.ws.close(4000, 'reconnect');
|
|
350
|
+
this.ws = null;
|
|
351
|
+
}
|
|
352
|
+
await this.connect();
|
|
353
|
+
return this.connectionState === 'open';
|
|
354
|
+
} catch (err: any) {
|
|
355
|
+
this.logger.error(`Recovery connect failed: ${err.message}`);
|
|
356
|
+
return false;
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
// Stream reconnect (resume from stream state)
|
|
361
|
+
this.recoveryManager.streamReconnect = async (): Promise<boolean> => {
|
|
362
|
+
try {
|
|
363
|
+
this.logger.info('Attempting stream reconnect (resume session)...');
|
|
364
|
+
// Reconnect tapi keep session state
|
|
365
|
+
if (this.ws) {
|
|
366
|
+
this.ws.close(4000, 'stream_reconnect');
|
|
367
|
+
this.ws = null;
|
|
368
|
+
}
|
|
369
|
+
// Quick reconnect dengan credentials yang sudah ada
|
|
370
|
+
await this.connect();
|
|
371
|
+
return this.connectionState === 'open';
|
|
372
|
+
} catch (err: any) {
|
|
373
|
+
this.logger.error(`Stream reconnect failed: ${err.message}`);
|
|
374
|
+
return false;
|
|
375
|
+
}
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
// Key refresh via SKDM
|
|
379
|
+
this.recoveryManager.refreshKeys = async (): Promise<void> => {
|
|
380
|
+
try {
|
|
381
|
+
this.logger.info('Refreshing Signal keys via SKDM...');
|
|
382
|
+
if (this.skdm) {
|
|
383
|
+
await this.skdm.preKeyManager.regeneratePreKeys();
|
|
384
|
+
this.skdm.markKeysDirty();
|
|
385
|
+
this.skdm.markCredsDirty();
|
|
386
|
+
await this.skdm.flush();
|
|
387
|
+
}
|
|
388
|
+
} catch (err: any) {
|
|
389
|
+
this.logger.error(`Key refresh failed: ${err.message}`);
|
|
390
|
+
}
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
// Pre-key fetch
|
|
394
|
+
this.recoveryManager.fetchPreKeys = async (): Promise<void> => {
|
|
395
|
+
try {
|
|
396
|
+
this.logger.info('Fetching pre-keys...');
|
|
397
|
+
if (this.skdm) {
|
|
398
|
+
await this.skdm.preKeyManager.regeneratePreKeys();
|
|
399
|
+
// Force upload ke server (will happen on next connect)
|
|
400
|
+
}
|
|
401
|
+
} catch (err: any) {
|
|
402
|
+
this.logger.error(`Pre-key fetch failed: ${err.message}`);
|
|
403
|
+
}
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
// Full re-auth (new QR)
|
|
407
|
+
this.recoveryManager.startFullReauth = async (): Promise<void> => {
|
|
408
|
+
try {
|
|
409
|
+
this.logger.info('Starting full re-authentication...');
|
|
410
|
+
// Clear existing creds untuk force new QR
|
|
411
|
+
if (this.skdm) {
|
|
412
|
+
// Keep noiseKey, tapi reset registration state
|
|
413
|
+
this.authState.creds.registered = false;
|
|
414
|
+
this.authState.creds.serverToken = undefined;
|
|
415
|
+
this.authState.creds.clientToken = undefined;
|
|
416
|
+
this.skdm.markCredsDirty();
|
|
417
|
+
await this.skdm.flush();
|
|
418
|
+
}
|
|
419
|
+
// Next connect akan trigger QR auth
|
|
420
|
+
} catch (err: any) {
|
|
421
|
+
this.logger.error(`Full re-auth setup failed: ${err.message}`);
|
|
422
|
+
}
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
// Update app version
|
|
426
|
+
this.recoveryManager.updateAppVersion = async (): Promise<void> => {
|
|
427
|
+
try {
|
|
428
|
+
this.logger.info('Updating app/browser version...');
|
|
429
|
+
// Bump version untuk bypass version-restricted endpoints
|
|
430
|
+
const versions = ['1.0.0', '1.1.0', '1.2.0', '2.0.0'];
|
|
431
|
+
const currentVersion = this.config.browser[2];
|
|
432
|
+
const currentIdx = versions.indexOf(currentVersion);
|
|
433
|
+
const newVersion = versions[Math.min(currentIdx + 1, versions.length - 1)] ?? '2.0.0';
|
|
434
|
+
this.config.browser = [this.config.browser[0], this.config.browser[1], newVersion];
|
|
435
|
+
this.logger.info(`App version updated to ${newVersion}`);
|
|
436
|
+
} catch (err: any) {
|
|
437
|
+
this.logger.error(`Update app version failed: ${err.message}`);
|
|
438
|
+
}
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// ============== Connection ==============
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Start connection ke WhatsApp server
|
|
446
|
+
*/
|
|
447
|
+
async connect(): Promise<void> {
|
|
448
|
+
if (this.connectionState === 'open' || this.connectionState === 'connecting') {
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
this.connectionState = 'connecting';
|
|
453
|
+
this.ev.emit('connection.update', { connection: 'connecting' });
|
|
454
|
+
|
|
455
|
+
try {
|
|
456
|
+
// Create WebSocket
|
|
457
|
+
this.ws = new WAWebSocket({
|
|
458
|
+
url: this.config.wsURL!,
|
|
459
|
+
logger: this.logger,
|
|
460
|
+
browser: this.config.browser!,
|
|
461
|
+
connectTimeoutMs: this.config.connectTimeoutMs!,
|
|
462
|
+
keepAliveIntervalMs: this.config.keepAliveIntervalMs!,
|
|
463
|
+
noiseKey: this.authState.creds.noiseKey,
|
|
464
|
+
ramLevel: this.config.ramOptimizationLevel!
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
// Listen for messages
|
|
468
|
+
this.ws.on('message', (data: Uint8Array) => this.handleIncomingData(data));
|
|
469
|
+
this.ws.on('close', (code: number, reason: string) => this.handleDisconnect(code, reason));
|
|
470
|
+
this.ws.on('error', (err: Error) => {
|
|
471
|
+
this.logger.error(`Socket error: ${err.message}`);
|
|
472
|
+
this.ev.emit('connection.update', {
|
|
473
|
+
connection: 'close',
|
|
474
|
+
lastDisconnect: { error: err, output: { code: 0, reason: err.message } }
|
|
475
|
+
});
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
await this.ws.connect();
|
|
479
|
+
|
|
480
|
+
this.connectionState = 'open';
|
|
481
|
+
this.logger.info('WhatsApp connection established');
|
|
482
|
+
|
|
483
|
+
// Initialize group manager & call handler
|
|
484
|
+
this.groupManager = new GroupManager({
|
|
485
|
+
send: (data) => this.send(data),
|
|
486
|
+
logger: this.logger,
|
|
487
|
+
registerIqHandler: (id, handler) => {
|
|
488
|
+
this.iqHandlers.set(id, handler);
|
|
489
|
+
}
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
this.callHandler = new CallHandler({
|
|
493
|
+
send: (data) => this.send(data),
|
|
494
|
+
logger: this.logger,
|
|
495
|
+
onCall: (event) => this.ev.emit('calls', event),
|
|
496
|
+
autoReject: false
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
// Start auth flow
|
|
500
|
+
if (!this.authState.creds.registered) {
|
|
501
|
+
if (this.config.mobile && this.config.phoneNumber) {
|
|
502
|
+
await this.requestPairingCode(this.config.phoneNumber);
|
|
503
|
+
} else {
|
|
504
|
+
await this.startQRAuth();
|
|
505
|
+
}
|
|
506
|
+
} else {
|
|
507
|
+
// Already registered - send login
|
|
508
|
+
await this.sendLogin();
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// Initialize SKDM dan start background jobs
|
|
512
|
+
await this.skdm?.initialize();
|
|
513
|
+
|
|
514
|
+
// Start heartbeat (Baileys fix #4: connection hang)
|
|
515
|
+
this.heartbeat = new ConnectionHeartbeat({
|
|
516
|
+
sendPing: async () => {
|
|
517
|
+
// Send WA ping frame
|
|
518
|
+
const { encodeBinaryNode } = await import('../core/binary.js');
|
|
519
|
+
const pingNode = {
|
|
520
|
+
tag: 'iq',
|
|
521
|
+
attrs: { type: 'get', id: `ping-${Date.now()}` },
|
|
522
|
+
content: [{ tag: 'ping', attrs: {} }]
|
|
523
|
+
};
|
|
524
|
+
this.send(encodeBinaryNode(pingNode));
|
|
525
|
+
},
|
|
526
|
+
onDead: () => {
|
|
527
|
+
this.logger.error('Connection detected as dead, triggering reconnect');
|
|
528
|
+
this.handleDisconnect(4101, 'heartbeat dead');
|
|
529
|
+
},
|
|
530
|
+
logger: this.logger,
|
|
531
|
+
pingIntervalMs: this.config.keepAliveIntervalMs,
|
|
532
|
+
pongTimeoutMs: 10000,
|
|
533
|
+
maxMissedPongs: 3
|
|
534
|
+
});
|
|
535
|
+
this.heartbeat.start();
|
|
536
|
+
|
|
537
|
+
// Start group event coalescer (Baileys fix #7: group events missing)
|
|
538
|
+
this.groupEventCoalescer = new GroupEventCoalescer(
|
|
539
|
+
(events) => {
|
|
540
|
+
for (const event of events) {
|
|
541
|
+
this.ev.emit('group-participants.update', event);
|
|
542
|
+
}
|
|
543
|
+
},
|
|
544
|
+
100,
|
|
545
|
+
this.logger
|
|
546
|
+
);
|
|
547
|
+
|
|
548
|
+
// Start health monitor
|
|
549
|
+
this.healthMonitor.start(60000);
|
|
550
|
+
this.healthMonitor.recordPing();
|
|
551
|
+
|
|
552
|
+
// Send pending messages dari persistence (recover after restart)
|
|
553
|
+
const pendingMessages = this.messagePersistence.getPending();
|
|
554
|
+
if (pendingMessages.length > 0) {
|
|
555
|
+
this.logger.info(`Recovering ${pendingMessages.length} pending messages from persistence`);
|
|
556
|
+
for (const msg of pendingMessages) {
|
|
557
|
+
// Re-queue untuk send
|
|
558
|
+
this.sendMessage(msg.recipientJid, msg.content).catch(err => {
|
|
559
|
+
this.logger.error(`Failed to recover message ${msg.id}: ${err.message}`);
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
this.ev.emit('connection.update', { connection: 'open' });
|
|
565
|
+
|
|
566
|
+
} catch (err: any) {
|
|
567
|
+
this.connectionState = 'close';
|
|
568
|
+
this.logger.error(`Connect failed: ${err.message}`);
|
|
569
|
+
this.ev.emit('connection.update', {
|
|
570
|
+
connection: 'close',
|
|
571
|
+
lastDisconnect: { error: err, output: { code: 0, reason: err.message } }
|
|
572
|
+
});
|
|
573
|
+
throw err;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Handle incoming data dari WebSocket
|
|
579
|
+
*/
|
|
580
|
+
private handleIncomingData(data: Uint8Array): void {
|
|
581
|
+
try {
|
|
582
|
+
const node = decodeBinaryNode(data);
|
|
583
|
+
if (!node) return;
|
|
584
|
+
|
|
585
|
+
// Record activity for heartbeat (Baileys fix #4)
|
|
586
|
+
this.heartbeat?.receivedPong();
|
|
587
|
+
this.healthMonitor.recordPing();
|
|
588
|
+
this.healthMonitor.recordReceive();
|
|
589
|
+
|
|
590
|
+
// Trigger webhook untuk incoming node (Features: webhook system)
|
|
591
|
+
this.webhookManager.trigger('node.received', { tag: node.tag, attrs: node.attrs });
|
|
592
|
+
|
|
593
|
+
// Check if it's an IQ response
|
|
594
|
+
if (node.tag === 'iq' && node.attrs?.id) {
|
|
595
|
+
// Check if it's a pong response
|
|
596
|
+
if (node.attrs.id?.startsWith('ping-')) {
|
|
597
|
+
this.heartbeat?.receivedPong();
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
const handler = this.iqHandlers.get(node.attrs.id);
|
|
601
|
+
if (handler) {
|
|
602
|
+
this.iqHandlers.delete(node.attrs.id);
|
|
603
|
+
handler(node);
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// Handle by tag
|
|
609
|
+
switch (node.tag) {
|
|
610
|
+
case 'message': {
|
|
611
|
+
// Baileys fix #6: duplicate messages - check idempotency
|
|
612
|
+
const messageId = node.attrs?.id;
|
|
613
|
+
if (messageId && this.idempotencyCache.isDuplicate(messageId)) {
|
|
614
|
+
this.logger.debug(`Skipping duplicate message: ${messageId}`);
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
this.messageReceiver.handleNode(node);
|
|
618
|
+
break;
|
|
619
|
+
}
|
|
620
|
+
case 'receipt':
|
|
621
|
+
case 'status':
|
|
622
|
+
case 'ack':
|
|
623
|
+
this.messageReceiver.handleNode(node);
|
|
624
|
+
break;
|
|
625
|
+
case 'call':
|
|
626
|
+
this.callHandler?.handleCallNode(node);
|
|
627
|
+
break;
|
|
628
|
+
case 'chat':
|
|
629
|
+
this.handleChatNode(node);
|
|
630
|
+
break;
|
|
631
|
+
case 'presence':
|
|
632
|
+
this.handlePresenceNode(node);
|
|
633
|
+
break;
|
|
634
|
+
case 'notification':
|
|
635
|
+
this.handleNotificationNode(node);
|
|
636
|
+
break;
|
|
637
|
+
case 'success':
|
|
638
|
+
this.logger.info('Authentication successful');
|
|
639
|
+
if (node.attrs?.user) {
|
|
640
|
+
this.user = { id: node.attrs.user, name: node.attrs.pushname };
|
|
641
|
+
this.authState.creds.me = node.attrs.user;
|
|
642
|
+
this.authState.creds.registered = true;
|
|
643
|
+
this.skdm?.markCredsDirty();
|
|
644
|
+
}
|
|
645
|
+
this.ev.emit('creds.update', this.authState.creds);
|
|
646
|
+
this.webhookManager.trigger('auth.success', { user: this.user });
|
|
647
|
+
break;
|
|
648
|
+
case 'failure':
|
|
649
|
+
this.logger.error('Authentication failed');
|
|
650
|
+
this.ev.emit('connection.update', {
|
|
651
|
+
connection: 'close',
|
|
652
|
+
lastDisconnect: { error: new Error('Auth failed'), output: node.attrs }
|
|
653
|
+
});
|
|
654
|
+
this.webhookManager.trigger('auth.failure', { attrs: node.attrs });
|
|
655
|
+
break;
|
|
656
|
+
default:
|
|
657
|
+
this.logger.trace(`Unhandled top-level node: ${node.tag}`);
|
|
658
|
+
}
|
|
659
|
+
} catch (err: any) {
|
|
660
|
+
this.logger.error(`Failed to process incoming data: ${err.message}`);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
private handleChatNode(node: BinaryNode): void {
|
|
665
|
+
const attrs = node.attrs || {};
|
|
666
|
+
// Chat presence: typing, recording
|
|
667
|
+
const from = attrs.from;
|
|
668
|
+
if (attrs.type === 'composing' || attrs.type === 'recording' || attrs.type === 'paused') {
|
|
669
|
+
this.ev.emit('presence.update', {
|
|
670
|
+
jid: from,
|
|
671
|
+
presence: attrs.type as any
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
private handlePresenceNode(node: BinaryNode): void {
|
|
677
|
+
const attrs = node.attrs || {};
|
|
678
|
+
const from = attrs.from;
|
|
679
|
+
const type = attrs.type as PresenceType;
|
|
680
|
+
if (from && type) {
|
|
681
|
+
this.ev.emit('presence.update', {
|
|
682
|
+
jid: from,
|
|
683
|
+
presence: type,
|
|
684
|
+
t: attrs.t ? parseInt(attrs.t, 10) : undefined
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
private handleNotificationNode(node: BinaryNode): void {
|
|
690
|
+
const attrs = node.attrs || {};
|
|
691
|
+
const type = attrs.type;
|
|
692
|
+
|
|
693
|
+
if (Array.isArray(node.content)) {
|
|
694
|
+
for (const child of node.content) {
|
|
695
|
+
if (typeof child !== 'object' || !child.tag) continue;
|
|
696
|
+
|
|
697
|
+
switch (child.tag) {
|
|
698
|
+
case 'add':
|
|
699
|
+
case 'remove':
|
|
700
|
+
case 'promote':
|
|
701
|
+
case 'demote':
|
|
702
|
+
// Group participant update
|
|
703
|
+
const participants = Array.isArray(child.content)
|
|
704
|
+
? child.content.filter(c => c?.tag === 'participant').map(c => c.attrs?.jid).filter(Boolean)
|
|
705
|
+
: [];
|
|
706
|
+
this.ev.emit('group-participants.update', {
|
|
707
|
+
id: attrs.from,
|
|
708
|
+
participants,
|
|
709
|
+
action: child.tag as any,
|
|
710
|
+
author: attrs.participant,
|
|
711
|
+
timestamp: Date.now()
|
|
712
|
+
});
|
|
713
|
+
break;
|
|
714
|
+
case 'subject':
|
|
715
|
+
// Group subject updated
|
|
716
|
+
this.ev.emit('groups.update', [{
|
|
717
|
+
id: attrs.from,
|
|
718
|
+
subject: child.attrs?.subject
|
|
719
|
+
}]);
|
|
720
|
+
break;
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
private handleDisconnect(code: number, reason: string): void {
|
|
727
|
+
this.connectionState = 'close';
|
|
728
|
+
this.pendingTracker.rejectAll(new Error(`Connection closed: ${code}`));
|
|
729
|
+
|
|
730
|
+
// Cleanup heartbeat
|
|
731
|
+
this.heartbeat?.stop();
|
|
732
|
+
this.heartbeat = null;
|
|
733
|
+
|
|
734
|
+
// Emit connection update
|
|
735
|
+
this.ev.emit('connection.update', {
|
|
736
|
+
connection: 'close',
|
|
737
|
+
lastDisconnect: {
|
|
738
|
+
error: undefined,
|
|
739
|
+
output: { code, reason }
|
|
740
|
+
}
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
// Skip recovery untuk intentional close (1000, 1001)
|
|
744
|
+
if (code === 1000 || code === 1001) {
|
|
745
|
+
this.logger.info('Intentional close, skipping recovery');
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// Skip jika recovery sudah in progress atau disabled
|
|
750
|
+
if (this.recoveryManager.isCurrentlyRecovering()) {
|
|
751
|
+
this.logger.warn('Recovery already in progress');
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// Map code ke DisconnectReason dan trigger multi-branch recovery
|
|
756
|
+
const disconnectReason: DisconnectReason = mapCodeToReason(code, reason);
|
|
757
|
+
this.logger.info(`Disconnect detected: ${disconnectReason} (code=${code}, reason=${reason})`);
|
|
758
|
+
|
|
759
|
+
// Trigger async recovery (non-blocking)
|
|
760
|
+
this.recoveryManager.recover(disconnectReason, code, reason).catch(err => {
|
|
761
|
+
this.logger.error(`Recovery manager error: ${err.message}`);
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// ============== Auth ==============
|
|
766
|
+
|
|
767
|
+
/**
|
|
768
|
+
* Start QR-based authentication
|
|
769
|
+
*/
|
|
770
|
+
private async startQRAuth(): Promise<void> {
|
|
771
|
+
const qrData = generateQRCodeData({
|
|
772
|
+
creds: this.authState.creds,
|
|
773
|
+
logger: this.logger,
|
|
774
|
+
printInTerminal: this.config.printQRInTerminal
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
this.ev.emit('qr.update', qrData);
|
|
778
|
+
this.ev.emit('connection.update', {
|
|
779
|
+
connection: 'connecting',
|
|
780
|
+
qr: qrData.qrString
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
// Setup refresh manager
|
|
784
|
+
this.qrRefreshManager = new QRRefreshManager(
|
|
785
|
+
(qr) => {
|
|
786
|
+
const newQr = generateQRCodeData({
|
|
787
|
+
creds: this.authState.creds,
|
|
788
|
+
logger: this.logger,
|
|
789
|
+
printInTerminal: this.config.printQRInTerminal
|
|
790
|
+
});
|
|
791
|
+
this.ev.emit('qr.update', newQr);
|
|
792
|
+
this.ev.emit('connection.update', {
|
|
793
|
+
connection: 'connecting',
|
|
794
|
+
qr: newQr.qrString
|
|
795
|
+
});
|
|
796
|
+
},
|
|
797
|
+
() => {
|
|
798
|
+
this.logger.warn('QR code timed out');
|
|
799
|
+
this.ev.emit('connection.update', {
|
|
800
|
+
connection: 'close',
|
|
801
|
+
lastDisconnect: {
|
|
802
|
+
error: new Error('QR timeout'),
|
|
803
|
+
output: { code: 0, reason: 'QR timeout' }
|
|
804
|
+
}
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
);
|
|
808
|
+
this.qrRefreshManager.start(qrData);
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/**
|
|
812
|
+
* Send login message untuk existing session
|
|
813
|
+
*/
|
|
814
|
+
private async sendLogin(): Promise<void> {
|
|
815
|
+
// Build login node dengan stored credentials
|
|
816
|
+
const loginNode: BinaryNode = {
|
|
817
|
+
tag: 'iq',
|
|
818
|
+
attrs: {
|
|
819
|
+
type: 'set',
|
|
820
|
+
to: 's.whatsapp.net',
|
|
821
|
+
id: `login-${Date.now()}`
|
|
822
|
+
},
|
|
823
|
+
content: [
|
|
824
|
+
{
|
|
825
|
+
tag: 'login',
|
|
826
|
+
attrs: {
|
|
827
|
+
user: this.authState.creds.me?.toString() ?? '',
|
|
828
|
+
serverToken: this.authState.creds.serverToken ?? '',
|
|
829
|
+
clientToken: this.authState.creds.clientToken ?? ''
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
]
|
|
833
|
+
};
|
|
834
|
+
this.send(encodeBinaryNode(loginNode));
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// ============== Internal Send ==============
|
|
838
|
+
|
|
839
|
+
private async send(data: Uint8Array): Promise<void> {
|
|
840
|
+
if (!this.ws || !this.ws.isConnected()) {
|
|
841
|
+
throw new Error('Cannot send: WebSocket not connected');
|
|
842
|
+
}
|
|
843
|
+
await this.rateLimiter.acquire();
|
|
844
|
+
this.ws.send(data);
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
// ============== Public API Methods ==============
|
|
848
|
+
|
|
849
|
+
async logout(): Promise<void> {
|
|
850
|
+
if (this.authState instanceof FileAuthState) {
|
|
851
|
+
await this.authState.clear();
|
|
852
|
+
}
|
|
853
|
+
await this.end(true);
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
async refreshQR(): Promise<QRGenerationData> {
|
|
857
|
+
const qrData = generateQRCodeData({
|
|
858
|
+
creds: this.authState.creds,
|
|
859
|
+
logger: this.logger,
|
|
860
|
+
printInTerminal: this.config.printQRInTerminal
|
|
861
|
+
});
|
|
862
|
+
this.ev.emit('qr.update', qrData);
|
|
863
|
+
return qrData;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
async requestPairingCode(phoneNumber: string): Promise<PairingCodeResult> {
|
|
867
|
+
const cleanPhone = normalizePhoneNumber(phoneNumber);
|
|
868
|
+
const result = generatePairingCode(cleanPhone, this.authState.creds, this.logger);
|
|
869
|
+
this.ev.emit('pairing-code.update', result);
|
|
870
|
+
return result;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
async sendMessage(jid: JIDLike, content: MessageContent, opts: any = {}): Promise<SendMessageResult> {
|
|
874
|
+
const recipientJid = jidToString(jid);
|
|
875
|
+
|
|
876
|
+
// Circuit breaker check (Features: circuit breaker)
|
|
877
|
+
if (!this.circuitBreaker.canSend(recipientJid)) {
|
|
878
|
+
const state = this.circuitBreaker.getState(recipientJid);
|
|
879
|
+
throw new Error(`Circuit breaker is ${state} for ${recipientJid} - too many failures`);
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
// Rate limit check (Features: rate limit tracker)
|
|
883
|
+
if (!this.rateLimitTracker.canSend(recipientJid)) {
|
|
884
|
+
const quota = this.rateLimitTracker.getRemainingQuota(recipientJid);
|
|
885
|
+
throw new Error(`Rate limit exceeded for ${recipientJid} (remaining: sec=${quota.second}, min=${quota.minute}, hr=${quota.hour})`);
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
// Persist message untuk survive restart (Features: message persistence)
|
|
889
|
+
const messageId = opts.messageId ?? `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
890
|
+
this.messagePersistence.save({
|
|
891
|
+
id: messageId,
|
|
892
|
+
recipientJid,
|
|
893
|
+
content,
|
|
894
|
+
status: 'pending',
|
|
895
|
+
attempts: 0,
|
|
896
|
+
createdAt: Date.now(),
|
|
897
|
+
updatedAt: Date.now()
|
|
898
|
+
});
|
|
899
|
+
|
|
900
|
+
// Use message queue untuk serialize sends per recipient (Baileys fix #8: race condition)
|
|
901
|
+
const startTime = Date.now();
|
|
902
|
+
try {
|
|
903
|
+
const result = await this.messageQueue.enqueue(recipientJid, () =>
|
|
904
|
+
this.messageSender.sendMessage(jid, content, { ...opts, messageId })
|
|
905
|
+
);
|
|
906
|
+
|
|
907
|
+
// Update persistence
|
|
908
|
+
this.messagePersistence.update(messageId, {
|
|
909
|
+
status: 'sent',
|
|
910
|
+
attempts: 1
|
|
911
|
+
});
|
|
912
|
+
|
|
913
|
+
// Record success
|
|
914
|
+
this.circuitBreaker.recordSuccess(recipientJid);
|
|
915
|
+
this.healthMonitor.recordSend(Date.now() - startTime, true);
|
|
916
|
+
|
|
917
|
+
// Trigger webhook
|
|
918
|
+
this.webhookManager.trigger('message.sent', { jid: recipientJid, messageId });
|
|
919
|
+
|
|
920
|
+
return result;
|
|
921
|
+
} catch (err: any) {
|
|
922
|
+
this.messagePersistence.update(messageId, {
|
|
923
|
+
status: 'failed',
|
|
924
|
+
lastError: err.message,
|
|
925
|
+
attempts: 1
|
|
926
|
+
});
|
|
927
|
+
this.circuitBreaker.recordFailure(recipientJid);
|
|
928
|
+
this.healthMonitor.recordSend(0, false);
|
|
929
|
+
|
|
930
|
+
// Trigger webhook untuk failure
|
|
931
|
+
this.webhookManager.trigger('message.failed', { jid: recipientJid, messageId, error: err.message });
|
|
932
|
+
|
|
933
|
+
throw err;
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
async forwardMessage(to: JIDLike, message: WAMessage): Promise<SendMessageResult> {
|
|
938
|
+
return this.messageSender.forwardMessage(to, message);
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
async deleteMessage(jid: JIDLike, key: WAMessageKey, opts: { revokeEveryone?: boolean } = {}): Promise<SendMessageResult> {
|
|
942
|
+
return this.messageSender.deleteMessage(jid, key, opts);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
async reactToMessage(jid: JIDLike, key: WAMessageKey, emoji: string): Promise<SendMessageResult> {
|
|
946
|
+
return this.messageSender.reactToMessage(jid, key, emoji);
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
async sendReadReceipt(jid: JIDLike, messageIds: string[], participant?: JIDLike): Promise<void> {
|
|
950
|
+
const node = buildReadReceiptNode(jid, messageIds, participant);
|
|
951
|
+
await this.send(encodeBinaryNode(node));
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
async sendPresenceUpdate(jid: JIDLike, presence: PresenceType): Promise<void> {
|
|
955
|
+
const node = buildChatPresenceNode(jid, presence as any);
|
|
956
|
+
await this.send(encodeBinaryNode(node));
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
async updatePresence(presence: 'available' | 'unavailable'): Promise<void> {
|
|
960
|
+
const node: BinaryNode = {
|
|
961
|
+
tag: 'presence',
|
|
962
|
+
attrs: { type: presence }
|
|
963
|
+
};
|
|
964
|
+
await this.send(encodeBinaryNode(node));
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
async uploadMedia(media: Buffer | Uint8Array, mediaType: string, mimetype: string, fileName?: string): Promise<any> {
|
|
968
|
+
return this.mediaUploader.upload(media, mediaType, mimetype, fileName);
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
async downloadMedia(url: string, mediaKey: Uint8Array, mediaType: string, mimetype: string, fileName?: string): Promise<any> {
|
|
972
|
+
return this.mediaDownloader.download(url, mediaKey, mediaType, mimetype, fileName);
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
// ============== Group Methods ==============
|
|
976
|
+
|
|
977
|
+
async groupCreate(opts: GroupCreateOptions): Promise<WAGroupMetadata> {
|
|
978
|
+
if (!this.groupManager) throw new Error('Group manager not initialized');
|
|
979
|
+
return this.groupManager.createGroup(opts);
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
async groupMetadata(jid: JIDLike): Promise<WAGroupMetadata> {
|
|
983
|
+
if (!this.groupManager) throw new Error('Group manager not initialized');
|
|
984
|
+
return this.groupManager.getGroupMetadata(jid);
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
async groupUpdateSubject(jid: JIDLike, subject: string): Promise<void> {
|
|
988
|
+
if (!this.groupManager) throw new Error('Group manager not initialized');
|
|
989
|
+
return this.groupManager.updateGroupSubject(jid, subject);
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
async groupUpdateDescription(jid: JIDLike, desc: string): Promise<void> {
|
|
993
|
+
if (!this.groupManager) throw new Error('Group manager not initialized');
|
|
994
|
+
return this.groupManager.updateGroupDescription(jid, desc);
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
async groupParticipantsUpdate(
|
|
998
|
+
jid: JIDLike,
|
|
999
|
+
participants: JIDLike[],
|
|
1000
|
+
action: 'add' | 'remove' | 'promote' | 'demote'
|
|
1001
|
+
): Promise<void> {
|
|
1002
|
+
if (!this.groupManager) throw new Error('Group manager not initialized');
|
|
1003
|
+
switch (action) {
|
|
1004
|
+
case 'add':
|
|
1005
|
+
return this.groupManager.addParticipants(jid, participants);
|
|
1006
|
+
case 'remove':
|
|
1007
|
+
return this.groupManager.removeParticipants(jid, participants);
|
|
1008
|
+
case 'promote':
|
|
1009
|
+
return this.groupManager.promoteParticipants(jid, participants);
|
|
1010
|
+
case 'demote':
|
|
1011
|
+
return this.groupManager.demoteParticipants(jid, participants);
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
async groupLeave(jid: JIDLike): Promise<void> {
|
|
1016
|
+
if (!this.groupManager) throw new Error('Group manager not initialized');
|
|
1017
|
+
return this.groupManager.leaveGroup(jid);
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
async groupInviteCode(jid: JIDLike): Promise<string> {
|
|
1021
|
+
if (!this.groupManager) throw new Error('Group manager not initialized');
|
|
1022
|
+
return this.groupManager.getInviteCode(jid);
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
async groupRevokeInvite(jid: JIDLike): Promise<string> {
|
|
1026
|
+
if (!this.groupManager) throw new Error('Group manager not initialized');
|
|
1027
|
+
return this.groupManager.revokeInviteCode(jid);
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
async groupJoinViaCode(code: string): Promise<JIDLike> {
|
|
1031
|
+
if (!this.groupManager) throw new Error('Group manager not initialized');
|
|
1032
|
+
return this.groupManager.joinGroupViaCode(code);
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
async groupSettingUpdate(jid: JIDLike, setting: 'restrict' | 'announce', value: boolean): Promise<void> {
|
|
1036
|
+
if (!this.groupManager) throw new Error('Group manager not initialized');
|
|
1037
|
+
if (setting === 'restrict') {
|
|
1038
|
+
return this.groupManager.setGroupRestrict(jid, value);
|
|
1039
|
+
} else {
|
|
1040
|
+
return this.groupManager.setGroupAnnounce(jid, value);
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
// ============== Call Methods ==============
|
|
1045
|
+
|
|
1046
|
+
async rejectCall(callId: string, from: JIDLike): Promise<void> {
|
|
1047
|
+
if (!this.callHandler) throw new Error('Call handler not initialized');
|
|
1048
|
+
return this.callHandler.rejectCall(callId, from);
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
// ============== Chat Methods ==============
|
|
1052
|
+
|
|
1053
|
+
async chatModify(modification: any, jid: JIDLike): Promise<void> {
|
|
1054
|
+
const node: BinaryNode = {
|
|
1055
|
+
tag: 'iq',
|
|
1056
|
+
attrs: {
|
|
1057
|
+
type: 'set',
|
|
1058
|
+
to: jidToString(jid),
|
|
1059
|
+
id: `chatmod-${Date.now()}`
|
|
1060
|
+
},
|
|
1061
|
+
content: [modification]
|
|
1062
|
+
};
|
|
1063
|
+
await this.send(encodeBinaryNode(node));
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
async readChat(jid: JIDLike): Promise<void> {
|
|
1067
|
+
const node: BinaryNode = {
|
|
1068
|
+
tag: 'iq',
|
|
1069
|
+
attrs: {
|
|
1070
|
+
type: 'set',
|
|
1071
|
+
to: jidToString(jid),
|
|
1072
|
+
id: `readchat-${Date.now()}`
|
|
1073
|
+
},
|
|
1074
|
+
content: [{
|
|
1075
|
+
tag: 'markChatAsRead',
|
|
1076
|
+
attrs: {}
|
|
1077
|
+
}]
|
|
1078
|
+
};
|
|
1079
|
+
await this.send(encodeBinaryNode(node));
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
async archiveChat(jid: JIDLike, archive: boolean): Promise<void> {
|
|
1083
|
+
const action = archive ? 'archive' : 'unarchive';
|
|
1084
|
+
const node: BinaryNode = {
|
|
1085
|
+
tag: 'iq',
|
|
1086
|
+
attrs: {
|
|
1087
|
+
type: 'set',
|
|
1088
|
+
to: jidToString(jid),
|
|
1089
|
+
id: `archive-${Date.now()}`
|
|
1090
|
+
},
|
|
1091
|
+
content: [{
|
|
1092
|
+
tag: 'chat',
|
|
1093
|
+
attrs: { action }
|
|
1094
|
+
}]
|
|
1095
|
+
};
|
|
1096
|
+
await this.send(encodeBinaryNode(node));
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
async muteChat(jid: JIDLike, durationMs: number): Promise<void> {
|
|
1100
|
+
const node: BinaryNode = {
|
|
1101
|
+
tag: 'iq',
|
|
1102
|
+
attrs: {
|
|
1103
|
+
type: 'set',
|
|
1104
|
+
to: jidToString(jid),
|
|
1105
|
+
id: `mute-${Date.now()}`
|
|
1106
|
+
},
|
|
1107
|
+
content: [{
|
|
1108
|
+
tag: 'mute',
|
|
1109
|
+
attrs: {
|
|
1110
|
+
action: 'mute',
|
|
1111
|
+
muteDuration: Math.floor(durationMs / 1000).toString()
|
|
1112
|
+
}
|
|
1113
|
+
}]
|
|
1114
|
+
};
|
|
1115
|
+
await this.send(encodeBinaryNode(node));
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
async pinChat(jid: JIDLike, durationMs: number): Promise<void> {
|
|
1119
|
+
const node: BinaryNode = {
|
|
1120
|
+
tag: 'iq',
|
|
1121
|
+
attrs: {
|
|
1122
|
+
type: 'set',
|
|
1123
|
+
to: jidToString(jid),
|
|
1124
|
+
id: `pin-${Date.now()}`
|
|
1125
|
+
},
|
|
1126
|
+
content: [{
|
|
1127
|
+
tag: 'pin',
|
|
1128
|
+
attrs: {
|
|
1129
|
+
type: 'pinV2',
|
|
1130
|
+
pinDuration: Math.floor(durationMs / 1000).toString()
|
|
1131
|
+
}
|
|
1132
|
+
}]
|
|
1133
|
+
};
|
|
1134
|
+
await this.send(encodeBinaryNode(node));
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
async deleteChat(jid: JIDLike): Promise<void> {
|
|
1138
|
+
const node: BinaryNode = {
|
|
1139
|
+
tag: 'iq',
|
|
1140
|
+
attrs: {
|
|
1141
|
+
type: 'set',
|
|
1142
|
+
to: jidToString(jid),
|
|
1143
|
+
id: `delchat-${Date.now()}`
|
|
1144
|
+
},
|
|
1145
|
+
content: [{
|
|
1146
|
+
tag: 'chat',
|
|
1147
|
+
attrs: { action: 'clear' }
|
|
1148
|
+
}]
|
|
1149
|
+
};
|
|
1150
|
+
await this.send(encodeBinaryNode(node));
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
async presenceSubscribe(jids: JIDLike[]): Promise<void> {
|
|
1154
|
+
const node: BinaryNode = {
|
|
1155
|
+
tag: 'iq',
|
|
1156
|
+
attrs: {
|
|
1157
|
+
type: 'set',
|
|
1158
|
+
to: 's.whatsapp.net',
|
|
1159
|
+
id: `presence-sub-${Date.now()}`
|
|
1160
|
+
},
|
|
1161
|
+
content: jids.map(j => ({
|
|
1162
|
+
tag: 'user',
|
|
1163
|
+
attrs: { jid: jidToString(j) }
|
|
1164
|
+
}))
|
|
1165
|
+
};
|
|
1166
|
+
await this.send(encodeBinaryNode(node));
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
// ============== Connection Management ==============
|
|
1170
|
+
|
|
1171
|
+
async end(newConnection: boolean = false): Promise<void> {
|
|
1172
|
+
if (this.reconnectTimer) {
|
|
1173
|
+
clearTimeout(this.reconnectTimer);
|
|
1174
|
+
this.reconnectTimer = null;
|
|
1175
|
+
}
|
|
1176
|
+
if (this.qrRefreshManager) {
|
|
1177
|
+
this.qrRefreshManager.stop();
|
|
1178
|
+
this.qrRefreshManager = null;
|
|
1179
|
+
}
|
|
1180
|
+
// Cancel ongoing recovery
|
|
1181
|
+
this.recoveryManager?.cancel();
|
|
1182
|
+
// Cleanup fixes
|
|
1183
|
+
this.heartbeat?.stop();
|
|
1184
|
+
this.heartbeat = null;
|
|
1185
|
+
this.idempotencyCache.destroy();
|
|
1186
|
+
this.groupEventCoalescer?.destroy();
|
|
1187
|
+
this.groupEventCoalescer = null;
|
|
1188
|
+
this.decryptionRetryQueue?.destroy();
|
|
1189
|
+
this.decryptionRetryQueue = null;
|
|
1190
|
+
this.messageQueue.clearAll();
|
|
1191
|
+
|
|
1192
|
+
// Cleanup features
|
|
1193
|
+
this.healthMonitor.stop();
|
|
1194
|
+
this.webhookManager.destroy();
|
|
1195
|
+
|
|
1196
|
+
// Cleanup SKDM
|
|
1197
|
+
await this.skdm?.destroy();
|
|
1198
|
+
|
|
1199
|
+
this.pendingTracker.rejectAll(new Error('Connection ended'));
|
|
1200
|
+
this.rateLimiter.destroy();
|
|
1201
|
+
if (this.ws) {
|
|
1202
|
+
this.ws.close(1000, 'normal');
|
|
1203
|
+
this.ws = null;
|
|
1204
|
+
}
|
|
1205
|
+
this.connectionState = 'close';
|
|
1206
|
+
this.ev.emit('connection.update', { connection: 'close' });
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
// ============== Public Accessors untuk SKDM & Features ==============
|
|
1210
|
+
|
|
1211
|
+
/**
|
|
1212
|
+
* Get SKDM (Store Key Data Manager) instance
|
|
1213
|
+
*/
|
|
1214
|
+
getSKDM(): SKDM | null {
|
|
1215
|
+
return this.skdm;
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
/**
|
|
1219
|
+
* Get health status
|
|
1220
|
+
*/
|
|
1221
|
+
getHealth() {
|
|
1222
|
+
return this.healthMonitor.getHealth();
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
/**
|
|
1226
|
+
* Get SKDM stats (pre-keys, sessions, identities)
|
|
1227
|
+
*/
|
|
1228
|
+
getSKDMStats() {
|
|
1229
|
+
return this.skdm?.getStats();
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
/**
|
|
1233
|
+
* Get system stats (queue size, circuit breakers, rate limits)
|
|
1234
|
+
*/
|
|
1235
|
+
getSystemStats() {
|
|
1236
|
+
return {
|
|
1237
|
+
messageQueue: this.messageQueue.getStats(),
|
|
1238
|
+
circuitBreakers: this.circuitBreaker.getOpenCircuits(),
|
|
1239
|
+
rateLimits: this.rateLimitTracker.getStats(),
|
|
1240
|
+
webhooks: this.webhookManager.getStats(),
|
|
1241
|
+
messagePersistence: {
|
|
1242
|
+
total: this.messagePersistence.size(),
|
|
1243
|
+
pending: this.messagePersistence.getPending().length,
|
|
1244
|
+
failed: this.messagePersistence.getFailed().length
|
|
1245
|
+
},
|
|
1246
|
+
idempotency: this.idempotencyCache.getStats(),
|
|
1247
|
+
historySync: this.historySyncManager.getStats(),
|
|
1248
|
+
health: this.healthMonitor.getHealth(),
|
|
1249
|
+
recovery: this.recoveryManager.getStats()
|
|
1250
|
+
};
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
/**
|
|
1254
|
+
* Get recovery stats - shows recovery success/failure rate per disconnect reason
|
|
1255
|
+
*/
|
|
1256
|
+
getRecoveryStats() {
|
|
1257
|
+
return this.recoveryManager.getStats();
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
/**
|
|
1261
|
+
* Get current recovery context (jika lagi recovering)
|
|
1262
|
+
*/
|
|
1263
|
+
getCurrentRecovery() {
|
|
1264
|
+
return this.recoveryManager.getCurrentContext();
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
/**
|
|
1268
|
+
* Check apakah lagi dalam proses recovery
|
|
1269
|
+
*/
|
|
1270
|
+
isRecovering(): boolean {
|
|
1271
|
+
return this.recoveryManager.isCurrentlyRecovering();
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
/**
|
|
1275
|
+
* Manually trigger recovery untuk a reason (untuk testing)
|
|
1276
|
+
*/
|
|
1277
|
+
async triggerRecovery(reason: DisconnectReason, code?: number, message?: string): Promise<boolean> {
|
|
1278
|
+
return this.recoveryManager.recover(reason, code, message);
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
/**
|
|
1282
|
+
* Register webhook
|
|
1283
|
+
*/
|
|
1284
|
+
registerWebhook(config: import('../features/index.js').WebhookConfig): void {
|
|
1285
|
+
this.webhookManager.register(config);
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
/**
|
|
1289
|
+
* Retry failed messages dari persistence
|
|
1290
|
+
*/
|
|
1291
|
+
async retryFailedMessages(): Promise<void> {
|
|
1292
|
+
const failed = this.messagePersistence.getFailed();
|
|
1293
|
+
this.logger.info(`Retrying ${failed.length} failed messages`);
|
|
1294
|
+
for (const msg of failed) {
|
|
1295
|
+
this.messagePersistence.update(msg.id, { status: 'pending' });
|
|
1296
|
+
try {
|
|
1297
|
+
await this.sendMessage(msg.recipientJid, msg.content);
|
|
1298
|
+
} catch (err: any) {
|
|
1299
|
+
this.logger.error(`Retry failed for ${msg.id}: ${err.message}`);
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
/**
|
|
1305
|
+
* Reset circuit breaker untuk a recipient
|
|
1306
|
+
*/
|
|
1307
|
+
resetCircuitBreaker(recipientJid: string): void {
|
|
1308
|
+
this.circuitBreaker.reset(recipientJid);
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
getConnectionState(): 'close' | 'open' | 'connecting' {
|
|
1312
|
+
return this.connectionState;
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
// ============== Factory Function ==============
|
|
1317
|
+
|
|
1318
|
+
/**
|
|
1319
|
+
* Create WhatsApp socket - main entry point
|
|
1320
|
+
* Mirip Baileys' makeWASocket()
|
|
1321
|
+
*
|
|
1322
|
+
* @example
|
|
1323
|
+
* ```ts
|
|
1324
|
+
* import { makeWASocket, useFileAuthState } from 'waliwa';
|
|
1325
|
+
*
|
|
1326
|
+
* const { state } = await useFileAuthState('./auth');
|
|
1327
|
+
* const sock = makeWASocket({ authState: state, printQRInTerminal: true });
|
|
1328
|
+
* await sock.connect();
|
|
1329
|
+
*
|
|
1330
|
+
* sock.ev.on('message.upsert', async ({ messages }) => {
|
|
1331
|
+
* for (const msg of messages) {
|
|
1332
|
+
* console.log('Got message:', msg);
|
|
1333
|
+
* }
|
|
1334
|
+
* });
|
|
1335
|
+
* ```
|
|
1336
|
+
*/
|
|
1337
|
+
export function makeWASocket(config: WaliwaConfig): WASocket {
|
|
1338
|
+
if (!config.authState) {
|
|
1339
|
+
throw new Error('authState is required in config');
|
|
1340
|
+
}
|
|
1341
|
+
const socket = new WASocketImpl(config, config.authState);
|
|
1342
|
+
// Auto-connect
|
|
1343
|
+
socket.connect().catch(err => {
|
|
1344
|
+
config.logger?.error?.(`Initial connect failed: ${err.message}`);
|
|
1345
|
+
});
|
|
1346
|
+
return socket as unknown as WASocket;
|
|
1347
|
+
}
|