@kangwifi-pro/waliwa 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +600 -0
  3. package/dist/advanced/features.d.ts +211 -0
  4. package/dist/advanced/features.d.ts.map +1 -0
  5. package/dist/advanced/features.js +671 -0
  6. package/dist/advanced/features.js.map +1 -0
  7. package/dist/auth/auth-state.d.ts +89 -0
  8. package/dist/auth/auth-state.d.ts.map +1 -0
  9. package/dist/auth/auth-state.js +330 -0
  10. package/dist/auth/auth-state.js.map +1 -0
  11. package/dist/auth/pairing-code.d.ts +78 -0
  12. package/dist/auth/pairing-code.d.ts.map +1 -0
  13. package/dist/auth/pairing-code.js +191 -0
  14. package/dist/auth/pairing-code.js.map +1 -0
  15. package/dist/auth/qr-code.d.ts +60 -0
  16. package/dist/auth/qr-code.d.ts.map +1 -0
  17. package/dist/auth/qr-code.js +151 -0
  18. package/dist/auth/qr-code.js.map +1 -0
  19. package/dist/calls/handler.d.ts +61 -0
  20. package/dist/calls/handler.d.ts.map +1 -0
  21. package/dist/calls/handler.js +117 -0
  22. package/dist/calls/handler.js.map +1 -0
  23. package/dist/core/binary.d.ts +74 -0
  24. package/dist/core/binary.d.ts.map +1 -0
  25. package/dist/core/binary.js +448 -0
  26. package/dist/core/binary.js.map +1 -0
  27. package/dist/core/crypto.d.ts +60 -0
  28. package/dist/core/crypto.d.ts.map +1 -0
  29. package/dist/core/crypto.js +170 -0
  30. package/dist/core/crypto.js.map +1 -0
  31. package/dist/core/noise.d.ts +106 -0
  32. package/dist/core/noise.d.ts.map +1 -0
  33. package/dist/core/noise.js +307 -0
  34. package/dist/core/noise.js.map +1 -0
  35. package/dist/events/emitter.d.ts +44 -0
  36. package/dist/events/emitter.d.ts.map +1 -0
  37. package/dist/events/emitter.js +79 -0
  38. package/dist/events/emitter.js.map +1 -0
  39. package/dist/features/index.d.ts +342 -0
  40. package/dist/features/index.d.ts.map +1 -0
  41. package/dist/features/index.js +755 -0
  42. package/dist/features/index.js.map +1 -0
  43. package/dist/fixes/index.d.ts +333 -0
  44. package/dist/fixes/index.d.ts.map +1 -0
  45. package/dist/fixes/index.js +762 -0
  46. package/dist/fixes/index.js.map +1 -0
  47. package/dist/groups/management.d.ts +86 -0
  48. package/dist/groups/management.d.ts.map +1 -0
  49. package/dist/groups/management.js +443 -0
  50. package/dist/groups/management.js.map +1 -0
  51. package/dist/index.d.ts +65 -0
  52. package/dist/index.d.ts.map +1 -0
  53. package/dist/index.js +236 -0
  54. package/dist/index.js.map +1 -0
  55. package/dist/messages/media.d.ts +93 -0
  56. package/dist/messages/media.d.ts.map +1 -0
  57. package/dist/messages/media.js +252 -0
  58. package/dist/messages/media.js.map +1 -0
  59. package/dist/messages/message-encoder.d.ts +70 -0
  60. package/dist/messages/message-encoder.d.ts.map +1 -0
  61. package/dist/messages/message-encoder.js +453 -0
  62. package/dist/messages/message-encoder.js.map +1 -0
  63. package/dist/messages/send.d.ts +101 -0
  64. package/dist/messages/send.d.ts.map +1 -0
  65. package/dist/messages/send.js +409 -0
  66. package/dist/messages/send.js.map +1 -0
  67. package/dist/recovery/index.d.ts +125 -0
  68. package/dist/recovery/index.d.ts.map +1 -0
  69. package/dist/recovery/index.js +584 -0
  70. package/dist/recovery/index.js.map +1 -0
  71. package/dist/skdm/index.d.ts +220 -0
  72. package/dist/skdm/index.d.ts.map +1 -0
  73. package/dist/skdm/index.js +600 -0
  74. package/dist/skdm/index.js.map +1 -0
  75. package/dist/socket/hybrid.d.ts +118 -0
  76. package/dist/socket/hybrid.d.ts.map +1 -0
  77. package/dist/socket/hybrid.js +352 -0
  78. package/dist/socket/hybrid.js.map +1 -0
  79. package/dist/socket/wa-socket.d.ts +300 -0
  80. package/dist/socket/wa-socket.d.ts.map +1 -0
  81. package/dist/socket/wa-socket.js +1094 -0
  82. package/dist/socket/wa-socket.js.map +1 -0
  83. package/dist/socket/ws-socket.d.ts +96 -0
  84. package/dist/socket/ws-socket.d.ts.map +1 -0
  85. package/dist/socket/ws-socket.js +302 -0
  86. package/dist/socket/ws-socket.js.map +1 -0
  87. package/dist/types/index.d.ts +444 -0
  88. package/dist/types/index.d.ts.map +1 -0
  89. package/dist/types/index.js +7 -0
  90. package/dist/types/index.js.map +1 -0
  91. package/dist/utils/jid.d.ts +46 -0
  92. package/dist/utils/jid.d.ts.map +1 -0
  93. package/dist/utils/jid.js +152 -0
  94. package/dist/utils/jid.js.map +1 -0
  95. package/dist/utils/logger.d.ts +45 -0
  96. package/dist/utils/logger.d.ts.map +1 -0
  97. package/dist/utils/logger.js +79 -0
  98. package/dist/utils/logger.js.map +1 -0
  99. package/dist/utils/retry.d.ts +43 -0
  100. package/dist/utils/retry.d.ts.map +1 -0
  101. package/dist/utils/retry.js +175 -0
  102. package/dist/utils/retry.js.map +1 -0
  103. package/docs/API.md +745 -0
  104. package/docs/ARCHITECTURE.md +307 -0
  105. package/docs/BAILEYS_FIXES.md +360 -0
  106. package/docs/FEATURES.md +532 -0
  107. package/docs/PROTOCOL.md +489 -0
  108. package/docs/RECOVERY.md +409 -0
  109. package/docs/SKDM.md +233 -0
  110. package/examples/ai-bot/index.ts +479 -0
  111. package/examples/ai-bot/package.json +17 -0
  112. package/examples/echo-bot/index.ts +171 -0
  113. package/examples/echo-bot/package.json +18 -0
  114. package/examples/group-bot/index.ts +557 -0
  115. package/examples/group-bot/package.json +17 -0
  116. package/examples/rest-gateway/index.ts +499 -0
  117. package/examples/rest-gateway/package.json +19 -0
  118. package/package.json +75 -0
  119. package/src/advanced/features.ts +817 -0
  120. package/src/auth/auth-state.ts +342 -0
  121. package/src/auth/pairing-code.ts +246 -0
  122. package/src/auth/qr-code.ts +191 -0
  123. package/src/calls/handler.ts +153 -0
  124. package/src/core/binary.ts +464 -0
  125. package/src/core/crypto.ts +189 -0
  126. package/src/core/noise.ts +406 -0
  127. package/src/events/emitter.ts +88 -0
  128. package/src/features/index.ts +921 -0
  129. package/src/fixes/index.ts +882 -0
  130. package/src/groups/management.ts +497 -0
  131. package/src/index.ts +274 -0
  132. package/src/messages/media.ts +372 -0
  133. package/src/messages/message-encoder.ts +520 -0
  134. package/src/messages/send.ts +521 -0
  135. package/src/recovery/index.ts +704 -0
  136. package/src/skdm/index.ts +693 -0
  137. package/src/socket/hybrid.ts +414 -0
  138. package/src/socket/wa-socket.ts +1347 -0
  139. package/src/socket/ws-socket.ts +355 -0
  140. package/src/types/index.ts +457 -0
  141. package/src/utils/jid.ts +156 -0
  142. package/src/utils/logger.ts +84 -0
  143. package/src/utils/retry.ts +205 -0
  144. package/tsconfig.json +30 -0
@@ -0,0 +1,921 @@
1
+ /**
2
+ * Waliwa - New Features Module
3
+ *
4
+ * Module yang berisi fitur-fitur baru yang gak ada di Baileys:
5
+ *
6
+ * 1. Health Monitor - real-time health tracking dengan auto-recovery
7
+ * 2. Multi-account Manager - manage multiple WhatsApp accounts
8
+ * 3. Webhook System - reliable webhooks dengan retry + HMAC signature
9
+ * 4. Circuit Breaker - prevent retry storms untuk failed sends
10
+ * 5. Rate Limit Tracker - per-recipient rate limit tracking
11
+ */
12
+
13
+ import { EventEmitter } from 'events';
14
+ import type { WALogger, JIDLike, WAMessage } from '../types/index.js';
15
+ import { SilentLogger } from '../utils/logger.js';
16
+ import { jidToString } from '../utils/jid.js';
17
+ import { LRUCache } from '../skdm/index.js';
18
+ import { createHmac } from 'crypto';
19
+
20
+ // ============================================================
21
+ // FEATURE 1: Health Monitor dengan auto-recovery
22
+ // ============================================================
23
+
24
+ export interface HealthStatus {
25
+ status: 'healthy' | 'degraded' | 'unhealthy';
26
+ score: number; // 0-100
27
+ connection: {
28
+ connected: boolean;
29
+ uptime: number;
30
+ lastPingTime: number;
31
+ reconnectCount: number;
32
+ };
33
+ metrics: {
34
+ messagesSent: number;
35
+ messagesReceived: number;
36
+ sendFailures: number;
37
+ avgLatencyMs: number;
38
+ };
39
+ resources: {
40
+ memoryUsageMb: number;
41
+ pendingSends: number;
42
+ queueSize: number;
43
+ };
44
+ issues: string[];
45
+ timestamp: number;
46
+ }
47
+
48
+ export class HealthMonitor extends EventEmitter {
49
+ private logger: WALogger;
50
+ private startTime: number;
51
+ private lastPingTime: number = 0;
52
+ private reconnectCount: number = 0;
53
+ private messagesSent: number = 0;
54
+ private messagesReceived: number = 0;
55
+ private sendFailures: number = 0;
56
+ private latencies: number[] = [];
57
+ private maxLatencySamples: number = 100;
58
+ private checkInterval: NodeJS.Timeout | null = null;
59
+ private autoRecoveryEnabled: boolean;
60
+
61
+ constructor(opts: { logger?: WALogger; autoRecovery?: boolean } = {}) {
62
+ super();
63
+ this.logger = opts.logger ?? new SilentLogger();
64
+ this.startTime = Date.now();
65
+ this.autoRecoveryEnabled = opts.autoRecovery ?? true;
66
+ }
67
+
68
+ /**
69
+ * Start monitoring - periodic health checks
70
+ */
71
+ start(intervalMs: number = 60000): void {
72
+ if (this.checkInterval) return;
73
+ this.checkInterval = setInterval(() => {
74
+ const status = this.getHealth();
75
+ this.emit('health:check', status);
76
+
77
+ if (status.status === 'unhealthy' && this.autoRecoveryEnabled) {
78
+ this.logger.warn('Health degraded, triggering recovery actions');
79
+ this.emit('health:recovery', status);
80
+ }
81
+ }, intervalMs);
82
+ }
83
+
84
+ stop(): void {
85
+ if (this.checkInterval) {
86
+ clearInterval(this.checkInterval);
87
+ this.checkInterval = null;
88
+ }
89
+ }
90
+
91
+ recordPing(): void {
92
+ this.lastPingTime = Date.now();
93
+ }
94
+
95
+ recordReconnect(): void {
96
+ this.reconnectCount++;
97
+ }
98
+
99
+ recordSend(latencyMs: number, success: boolean = true): void {
100
+ if (success) {
101
+ this.messagesSent++;
102
+ this.latencies.push(latencyMs);
103
+ if (this.latencies.length > this.maxLatencySamples) {
104
+ this.latencies.shift();
105
+ }
106
+ } else {
107
+ this.sendFailures++;
108
+ }
109
+ }
110
+
111
+ recordReceive(): void {
112
+ this.messagesReceived++;
113
+ }
114
+
115
+ getHealth(): HealthStatus {
116
+ const memUsage = process.memoryUsage();
117
+ const avgLatency = this.latencies.length > 0
118
+ ? this.latencies.reduce((a, b) => a + b, 0) / this.latencies.length
119
+ : 0;
120
+
121
+ const issues: string[] = [];
122
+ let score = 100;
123
+
124
+ // Check connection
125
+ const connected = this.lastPingTime > 0 && (Date.now() - this.lastPingTime) < 60000;
126
+ if (!connected) {
127
+ issues.push('Connection appears down (no recent ping)');
128
+ score -= 40;
129
+ }
130
+
131
+ // Check reconnect frequency
132
+ if (this.reconnectCount > 10) {
133
+ issues.push(`High reconnect count: ${this.reconnectCount}`);
134
+ score -= 20;
135
+ }
136
+
137
+ // Check send failure rate
138
+ const totalSends = this.messagesSent + this.sendFailures;
139
+ const failureRate = totalSends > 0 ? this.sendFailures / totalSends : 0;
140
+ if (failureRate > 0.1) {
141
+ issues.push(`High send failure rate: ${(failureRate * 100).toFixed(1)}%`);
142
+ score -= 25;
143
+ }
144
+
145
+ // Check latency
146
+ if (avgLatency > 5000) {
147
+ issues.push(`High average latency: ${avgLatency.toFixed(0)}ms`);
148
+ score -= 15;
149
+ }
150
+
151
+ // Check memory
152
+ const memMb = memUsage.rss / 1024 / 1024;
153
+ if (memMb > 500) {
154
+ issues.push(`High memory usage: ${memMb.toFixed(1)}MB`);
155
+ score -= 10;
156
+ }
157
+
158
+ let status: 'healthy' | 'degraded' | 'unhealthy';
159
+ if (score >= 80) status = 'healthy';
160
+ else if (score >= 50) status = 'degraded';
161
+ else status = 'unhealthy';
162
+
163
+ return {
164
+ status,
165
+ score: Math.max(0, score),
166
+ connection: {
167
+ connected,
168
+ uptime: Date.now() - this.startTime,
169
+ lastPingTime: this.lastPingTime,
170
+ reconnectCount: this.reconnectCount
171
+ },
172
+ metrics: {
173
+ messagesSent: this.messagesSent,
174
+ messagesReceived: this.messagesReceived,
175
+ sendFailures: this.sendFailures,
176
+ avgLatencyMs: avgLatency
177
+ },
178
+ resources: {
179
+ memoryUsageMb: memMb,
180
+ pendingSends: 0,
181
+ queueSize: 0
182
+ },
183
+ issues,
184
+ timestamp: Date.now()
185
+ };
186
+ }
187
+
188
+ reset(): void {
189
+ this.startTime = Date.now();
190
+ this.lastPingTime = 0;
191
+ this.reconnectCount = 0;
192
+ this.messagesSent = 0;
193
+ this.messagesReceived = 0;
194
+ this.sendFailures = 0;
195
+ this.latencies = [];
196
+ }
197
+ }
198
+
199
+ // ============================================================
200
+ // FEATURE 2: Multi-account Manager
201
+ // ============================================================
202
+
203
+ export interface AccountConfig {
204
+ id: string;
205
+ name: string;
206
+ authFolder: string;
207
+ enabled: boolean;
208
+ config: any; // WaliwaConfig
209
+ }
210
+
211
+ export interface AccountStatus {
212
+ id: string;
213
+ name: string;
214
+ connected: boolean;
215
+ user?: { id: string; name?: string };
216
+ lastActive: number;
217
+ messagesSent: number;
218
+ messagesReceived: number;
219
+ }
220
+
221
+ /**
222
+ * MultiAccountManager - manage multiple WhatsApp accounts dari satu process.
223
+ * Berguna untuk: customer service bots, multiple business accounts, dll.
224
+ *
225
+ * RAM optimization: lazy-load accounts, only active ones consume resources.
226
+ */
227
+ export class MultiAccountManager extends EventEmitter {
228
+ private accounts: Map<string, { config: AccountConfig; socket: any; status: AccountStatus }> = new Map();
229
+ private logger: WALogger;
230
+ private maxAccounts: number;
231
+
232
+ constructor(opts: { maxAccounts?: number; logger?: WALogger } = {}) {
233
+ super();
234
+ this.logger = opts.logger ?? new SilentLogger();
235
+ this.maxAccounts = opts.maxAccounts ?? 10;
236
+ }
237
+
238
+ /**
239
+ * Register new account
240
+ */
241
+ registerAccount(config: AccountConfig): void {
242
+ if (this.accounts.size >= this.maxAccounts) {
243
+ throw new Error(`Max accounts (${this.maxAccounts}) reached`);
244
+ }
245
+ if (this.accounts.has(config.id)) {
246
+ throw new Error(`Account with id ${config.id} already exists`);
247
+ }
248
+
249
+ this.accounts.set(config.id, {
250
+ config,
251
+ socket: null,
252
+ status: {
253
+ id: config.id,
254
+ name: config.name,
255
+ connected: false,
256
+ lastActive: Date.now(),
257
+ messagesSent: 0,
258
+ messagesReceived: 0
259
+ }
260
+ });
261
+
262
+ this.logger.info(`Account registered: ${config.id} (${config.name})`);
263
+ }
264
+
265
+ /**
266
+ * Connect account by ID
267
+ */
268
+ async connectAccount(accountId: string): Promise<void> {
269
+ const entry = this.accounts.get(accountId);
270
+ if (!entry) throw new Error(`Account ${accountId} not found`);
271
+
272
+ if (entry.socket) {
273
+ this.logger.warn(`Account ${accountId} already connected`);
274
+ return;
275
+ }
276
+
277
+ // Lazy load makeWASocket untuk hemat RAM
278
+ const { makeWASocket } = await import('../socket/wa-socket.js');
279
+ const { useFileAuthState } = await import('../auth/auth-state.js');
280
+ const state = await useFileAuthState(entry.config.authFolder, accountId);
281
+ const socket = makeWASocket({
282
+ ...entry.config.config,
283
+ authState: state
284
+ });
285
+
286
+ // Track connection
287
+ socket.ev.on('connection.update', (update: any) => {
288
+ if (update.connection === 'open') {
289
+ entry.status.connected = true;
290
+ entry.status.user = socket.user;
291
+ entry.status.lastActive = Date.now();
292
+ this.emit('account:connected', accountId);
293
+ } else if (update.connection === 'close') {
294
+ entry.status.connected = false;
295
+ this.emit('account:disconnected', accountId);
296
+ }
297
+ });
298
+
299
+ socket.ev.on('message.upsert', ({ messages }: any) => {
300
+ entry.status.messagesReceived += messages.length;
301
+ entry.status.lastActive = Date.now();
302
+ this.emit('account:message', accountId, messages);
303
+ });
304
+
305
+ entry.socket = socket;
306
+ this.logger.info(`Account ${accountId} connected`);
307
+ }
308
+
309
+ /**
310
+ * Disconnect account
311
+ */
312
+ async disconnectAccount(accountId: string): Promise<void> {
313
+ const entry = this.accounts.get(accountId);
314
+ if (!entry || !entry.socket) return;
315
+
316
+ await entry.socket.end();
317
+ entry.socket = null;
318
+ entry.status.connected = false;
319
+ this.logger.info(`Account ${accountId} disconnected`);
320
+ }
321
+
322
+ /**
323
+ * Get account socket (untuk send messages)
324
+ */
325
+ getSocket(accountId: string): any {
326
+ const entry = this.accounts.get(accountId);
327
+ return entry?.socket;
328
+ }
329
+
330
+ /**
331
+ * List all accounts dengan status
332
+ */
333
+ listAccounts(): AccountStatus[] {
334
+ return Array.from(this.accounts.values()).map(e => e.status);
335
+ }
336
+
337
+ /**
338
+ * Send message from specific account
339
+ */
340
+ async sendMessageFrom(accountId: string, jid: JIDLike, content: any): Promise<any> {
341
+ const entry = this.accounts.get(accountId);
342
+ if (!entry?.socket) {
343
+ throw new Error(`Account ${accountId} not connected`);
344
+ }
345
+
346
+ const result = await entry.socket.sendMessage(jid, content);
347
+ entry.status.messagesSent++;
348
+ entry.status.lastActive = Date.now();
349
+ return result;
350
+ }
351
+
352
+ /**
353
+ * Broadcast message dari multiple accounts
354
+ */
355
+ async broadcast(jid: JIDLike, content: any, accountIds?: string[]): Promise<void> {
356
+ const ids = accountIds ?? Array.from(this.accounts.keys());
357
+ const promises = ids.map(async id => {
358
+ try {
359
+ await this.sendMessageFrom(id, jid, content);
360
+ return { id, success: true };
361
+ } catch (err: any) {
362
+ this.logger.error(`Broadcast from ${id} failed: ${err.message}`);
363
+ return { id, success: false, error: err.message };
364
+ }
365
+ });
366
+ await Promise.all(promises);
367
+ }
368
+
369
+ /**
370
+ * Disconnect all accounts
371
+ */
372
+ async disconnectAll(): Promise<void> {
373
+ const promises = Array.from(this.accounts.keys()).map(id => this.disconnectAccount(id));
374
+ await Promise.all(promises);
375
+ }
376
+ }
377
+
378
+ // ============================================================
379
+ // FEATURE 3: Webhook System dengan HMAC signature + retry
380
+ // ============================================================
381
+
382
+ export interface WebhookConfig {
383
+ url: string;
384
+ events: string[]; // '*' untuk all events
385
+ secret?: string; // HMAC signing secret
386
+ enabled: boolean;
387
+ retryCount: number;
388
+ retryDelayMs: number;
389
+ timeoutMs: number;
390
+ }
391
+
392
+ interface PendingWebhook {
393
+ id: string;
394
+ url: string;
395
+ payload: any;
396
+ secret?: string;
397
+ attempts: number;
398
+ nextRetryAt: number;
399
+ }
400
+
401
+ /**
402
+ * WebhookManager - reliable webhook delivery dengan retry + HMAC signature.
403
+ * Fix common Baileys issue: webhooks tidak reliable.
404
+ *
405
+ * Strategy:
406
+ * - HMAC-SHA256 signature untuk verify authenticity
407
+ * - Exponential backoff retry
408
+ * - Queue pending webhooks jika network issue
409
+ * - Dead letter queue untuk permanent failures
410
+ */
411
+ export class WebhookManager {
412
+ private webhooks: Map<string, WebhookConfig> = new Map();
413
+ private pendingQueue: PendingWebhook[] = [];
414
+ private deadLetterQueue: PendingWebhook[] = [];
415
+ private logger: WALogger;
416
+ private processingTimer: NodeJS.Timeout | null = null;
417
+ private isProcessing: boolean = false;
418
+ private stats: { sent: number; failed: number; retried: number } = { sent: 0, failed: 0, retried: 0 };
419
+
420
+ constructor(opts: { logger?: WALogger } = {}) {
421
+ this.logger = opts.logger ?? new SilentLogger();
422
+ // Process queue every 5 seconds
423
+ this.processingTimer = setInterval(() => this.processQueue(), 5000);
424
+ }
425
+
426
+ /**
427
+ * Register webhook
428
+ */
429
+ register(config: WebhookConfig): void {
430
+ this.webhooks.set(config.url, config);
431
+ this.logger.info(`Webhook registered: ${config.url} for events: ${config.events.join(', ')}`);
432
+ }
433
+
434
+ /**
435
+ * Unregister webhook
436
+ */
437
+ unregister(url: string): void {
438
+ this.webhooks.delete(url);
439
+ }
440
+
441
+ /**
442
+ * Trigger webhook delivery
443
+ */
444
+ trigger(event: string, payload: any): void {
445
+ for (const config of this.webhooks.values()) {
446
+ if (!config.enabled) continue;
447
+ if (config.events.includes('*') || config.events.includes(event)) {
448
+ const id = `wh-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
449
+ this.pendingQueue.push({
450
+ id,
451
+ url: config.url,
452
+ payload: { event, timestamp: Date.now(), data: payload },
453
+ secret: config.secret,
454
+ attempts: 0,
455
+ nextRetryAt: Date.now()
456
+ });
457
+ }
458
+ }
459
+ }
460
+
461
+ private async processQueue(): Promise<void> {
462
+ if (this.isProcessing) return;
463
+ this.isProcessing = true;
464
+
465
+ const now = Date.now();
466
+ const toProcess = this.pendingQueue.filter(w => w.nextRetryAt <= now);
467
+ this.pendingQueue = this.pendingQueue.filter(w => w.nextRetryAt > now);
468
+
469
+ for (const webhook of toProcess) {
470
+ await this.deliver(webhook);
471
+ }
472
+
473
+ this.isProcessing = false;
474
+ }
475
+
476
+ private async deliver(webhook: PendingWebhook): Promise<void> {
477
+ const config = this.webhooks.get(webhook.url);
478
+ if (!config) {
479
+ // Webhook was unregistered, drop
480
+ return;
481
+ }
482
+
483
+ webhook.attempts++;
484
+ const body = JSON.stringify(webhook.payload);
485
+
486
+ // Sign with HMAC jika secret provided
487
+ const headers: Record<string, string> = {
488
+ 'Content-Type': 'application/json'
489
+ };
490
+ if (webhook.secret) {
491
+ const signature = createHmac('sha256', webhook.secret)
492
+ .update(body)
493
+ .digest('hex');
494
+ headers['X-Waliwa-Signature'] = `sha256=${signature}`;
495
+ headers['X-Waliwa-Event'] = webhook.payload.event;
496
+ }
497
+
498
+ try {
499
+ const controller = new AbortController();
500
+ const timeout = setTimeout(() => controller.abort(), config.timeoutMs);
501
+
502
+ const response = await fetch(webhook.url, {
503
+ method: 'POST',
504
+ headers,
505
+ body,
506
+ signal: controller.signal
507
+ });
508
+
509
+ clearTimeout(timeout);
510
+
511
+ if (response.status >= 200 && response.status < 300) {
512
+ this.stats.sent++;
513
+ this.logger.debug(`Webhook ${webhook.id} delivered successfully`);
514
+ } else if (response.status >= 400 && response.status < 500) {
515
+ // Client error - don't retry (4xx = permanent failure)
516
+ this.stats.failed++;
517
+ this.deadLetterQueue.push(webhook);
518
+ this.logger.warn(`Webhook ${webhook.id} rejected with ${response.status}, moved to DLQ`);
519
+ } else {
520
+ // Server error - retry
521
+ throw new Error(`HTTP ${response.status}`);
522
+ }
523
+ } catch (err: any) {
524
+ this.logger.warn(`Webhook ${webhook.id} delivery failed (attempt ${webhook.attempts}): ${err.message}`);
525
+
526
+ if (webhook.attempts >= config.retryCount) {
527
+ // Move to dead letter queue
528
+ this.deadLetterQueue.push(webhook);
529
+ this.stats.failed++;
530
+ this.logger.error(`Webhook ${webhook.id} permanently failed, moved to DLQ`);
531
+ } else {
532
+ // Schedule retry dengan exponential backoff
533
+ const delay = config.retryDelayMs * Math.pow(2, webhook.attempts - 1);
534
+ webhook.nextRetryAt = Date.now() + delay;
535
+ this.pendingQueue.push(webhook);
536
+ this.stats.retried++;
537
+ }
538
+ }
539
+ }
540
+
541
+ getStats(): { sent: number; failed: number; retried: number; pending: number; deadLettered: number } {
542
+ return {
543
+ ...this.stats,
544
+ pending: this.pendingQueue.length,
545
+ deadLettered: this.deadLetterQueue.length
546
+ };
547
+ }
548
+
549
+ /**
550
+ * Get dead letter queue (untuk manual retry)
551
+ */
552
+ getDeadLetterQueue(): PendingWebhook[] {
553
+ return this.deadLetterQueue;
554
+ }
555
+
556
+ /**
557
+ * Retry dari DLQ
558
+ */
559
+ retryDeadLetter(id?: string): void {
560
+ if (id) {
561
+ const idx = this.deadLetterQueue.findIndex(w => w.id === id);
562
+ if (idx !== -1) {
563
+ const webhook = this.deadLetterQueue.splice(idx, 1)[0];
564
+ webhook.attempts = 0;
565
+ webhook.nextRetryAt = Date.now();
566
+ this.pendingQueue.push(webhook);
567
+ }
568
+ } else {
569
+ // Retry all
570
+ for (const webhook of this.deadLetterQueue) {
571
+ webhook.attempts = 0;
572
+ webhook.nextRetryAt = Date.now();
573
+ this.pendingQueue.push(webhook);
574
+ }
575
+ this.deadLetterQueue = [];
576
+ }
577
+ }
578
+
579
+ destroy(): void {
580
+ if (this.processingTimer) {
581
+ clearInterval(this.processingTimer);
582
+ this.processingTimer = null;
583
+ }
584
+ this.pendingQueue = [];
585
+ this.deadLetterQueue = [];
586
+ }
587
+ }
588
+
589
+ // ============================================================
590
+ // FEATURE 4: Circuit Breaker untuk failed sends
591
+ // ============================================================
592
+
593
+ /**
594
+ * CircuitBreaker - prevent retry storms untuk failed recipients.
595
+ * Fix Baileys issue: failed sends terus retry, flood server, get banned.
596
+ *
597
+ * Strategy:
598
+ * - Track failure count per recipient
599
+ * - When threshold reached, open circuit (stop sending)
600
+ * - After cooldown, try again (half-open state)
601
+ * - If success, close circuit; if fail, re-open
602
+ */
603
+ export class CircuitBreaker {
604
+ private states: Map<string, {
605
+ failureCount: number;
606
+ lastFailureTime: number;
607
+ state: 'closed' | 'open' | 'half-open';
608
+ }> = new Map();
609
+ private failureThreshold: number;
610
+ private cooldownMs: number;
611
+ private logger: WALogger;
612
+
613
+ constructor(opts: { failureThreshold?: number; cooldownMs?: number; logger?: WALogger } = {}) {
614
+ this.failureThreshold = opts.failureThreshold ?? 5;
615
+ this.cooldownMs = opts.cooldownMs ?? 60 * 1000; // 1 minute default
616
+ this.logger = opts.logger ?? new SilentLogger();
617
+ }
618
+
619
+ /**
620
+ * Check if can send to recipient
621
+ */
622
+ canSend(recipientJid: string): boolean {
623
+ const state = this.states.get(recipientJid);
624
+ if (!state) return true; // No state = closed
625
+
626
+ if (state.state === 'closed') return true;
627
+
628
+ if (state.state === 'open') {
629
+ // Check if cooldown elapsed
630
+ if (Date.now() - state.lastFailureTime > this.cooldownMs) {
631
+ // Half-open: allow one attempt
632
+ state.state = 'half-open';
633
+ this.logger.info(`Circuit half-open for ${recipientJid}`);
634
+ return true;
635
+ }
636
+ return false;
637
+ }
638
+
639
+ // half-open: only allow one concurrent request
640
+ return true;
641
+ }
642
+
643
+ /**
644
+ * Record successful send
645
+ */
646
+ recordSuccess(recipientJid: string): void {
647
+ const state = this.states.get(recipientJid);
648
+ if (state) {
649
+ state.failureCount = 0;
650
+ state.state = 'closed';
651
+ }
652
+ }
653
+
654
+ /**
655
+ * Record failed send
656
+ */
657
+ recordFailure(recipientJid: string): void {
658
+ let state = this.states.get(recipientJid);
659
+ if (!state) {
660
+ state = {
661
+ failureCount: 0,
662
+ lastFailureTime: Date.now(),
663
+ state: 'closed'
664
+ };
665
+ this.states.set(recipientJid, state);
666
+ }
667
+
668
+ state.failureCount++;
669
+ state.lastFailureTime = Date.now();
670
+
671
+ if (state.failureCount >= this.failureThreshold) {
672
+ state.state = 'open';
673
+ this.logger.warn(`Circuit opened for ${recipientJid} after ${state.failureCount} failures`);
674
+ }
675
+ }
676
+
677
+ /**
678
+ * Manually reset circuit untuk recipient
679
+ */
680
+ reset(recipientJid: string): void {
681
+ this.states.delete(recipientJid);
682
+ }
683
+
684
+ /**
685
+ * Get circuit state untuk recipient
686
+ */
687
+ getState(recipientJid: string): 'closed' | 'open' | 'half-open' {
688
+ return this.states.get(recipientJid)?.state ?? 'closed';
689
+ }
690
+
691
+ /**
692
+ * Get all open circuits (untuk monitoring)
693
+ */
694
+ getOpenCircuits(): Array<{ jid: string; failureCount: number; lastFailureTime: number }> {
695
+ const result: Array<{ jid: string; failureCount: number; lastFailureTime: number }> = [];
696
+ for (const [jid, state] of this.states.entries()) {
697
+ if (state.state === 'open') {
698
+ result.push({
699
+ jid,
700
+ failureCount: state.failureCount,
701
+ lastFailureTime: state.lastFailureTime
702
+ });
703
+ }
704
+ }
705
+ return result;
706
+ }
707
+
708
+ /**
709
+ * Cleanup expired circuits (cooldown elapsed and still open)
710
+ */
711
+ cleanup(): void {
712
+ const now = Date.now();
713
+ for (const [jid, state] of this.states.entries()) {
714
+ if (state.state === 'open' && (now - state.lastFailureTime) > this.cooldownMs * 2) {
715
+ this.states.delete(jid);
716
+ }
717
+ }
718
+ }
719
+ }
720
+
721
+ // ============================================================
722
+ // FEATURE 5: Rate Limit Tracker per recipient
723
+ // ============================================================
724
+
725
+ /**
726
+ * RateLimitTracker - per-recipient rate limiting.
727
+ * Berguna untuk: prevent spam, comply dengan WA server limits.
728
+ *
729
+ * WhatsApp server limits (approximate):
730
+ * - 5 messages/sec per recipient
731
+ * - 60 messages/min per recipient
732
+ * - 1000 messages/hour per recipient
733
+ */
734
+ export class RateLimitTracker {
735
+ private counts: Map<string, { second: number; minute: number; hour: number; lastReset: { second: number; minute: number; hour: number } }> = new Map();
736
+ private limits: { perSecond: number; perMinute: number; perHour: number };
737
+ private logger: WALogger;
738
+
739
+ constructor(opts: { perSecond?: number; perMinute?: number; perHour?: number; logger?: WALogger } = {}) {
740
+ this.limits = {
741
+ perSecond: opts.perSecond ?? 5,
742
+ perMinute: opts.perMinute ?? 60,
743
+ perHour: opts.perHour ?? 1000
744
+ };
745
+ this.logger = opts.logger ?? new SilentLogger();
746
+ }
747
+
748
+ /**
749
+ * Check if can send to recipient (and increment counter)
750
+ */
751
+ canSend(recipientJid: string): boolean {
752
+ const now = Date.now();
753
+ let entry = this.counts.get(recipientJid);
754
+ if (!entry) {
755
+ entry = {
756
+ second: 0,
757
+ minute: 0,
758
+ hour: 0,
759
+ lastReset: { second: now, minute: now, hour: now }
760
+ };
761
+ this.counts.set(recipientJid, entry);
762
+ }
763
+
764
+ // Reset counters jika time window elapsed
765
+ if (now - entry.lastReset.second > 1000) {
766
+ entry.second = 0;
767
+ entry.lastReset.second = now;
768
+ }
769
+ if (now - entry.lastReset.minute > 60000) {
770
+ entry.minute = 0;
771
+ entry.lastReset.minute = now;
772
+ }
773
+ if (now - entry.lastReset.hour > 3600000) {
774
+ entry.hour = 0;
775
+ entry.lastReset.hour = now;
776
+ }
777
+
778
+ // Check limits
779
+ if (entry.second >= this.limits.perSecond) return false;
780
+ if (entry.minute >= this.limits.perMinute) return false;
781
+ if (entry.hour >= this.limits.perHour) return false;
782
+
783
+ // Increment
784
+ entry.second++;
785
+ entry.minute++;
786
+ entry.hour++;
787
+ return true;
788
+ }
789
+
790
+ /**
791
+ * Get remaining quota untuk recipient
792
+ */
793
+ getRemainingQuota(recipientJid: string): { second: number; minute: number; hour: number } {
794
+ const entry = this.counts.get(recipientJid);
795
+ if (!entry) {
796
+ return { second: this.limits.perSecond, minute: this.limits.perMinute, hour: this.limits.perHour };
797
+ }
798
+ const now = Date.now();
799
+ return {
800
+ second: Math.max(0, this.limits.perSecond - (now - entry.lastReset.second > 1000 ? 0 : entry.second)),
801
+ minute: Math.max(0, this.limits.perMinute - (now - entry.lastReset.minute > 60000 ? 0 : entry.minute)),
802
+ hour: Math.max(0, this.limits.perHour - (now - entry.lastReset.hour > 3600000 ? 0 : entry.hour))
803
+ };
804
+ }
805
+
806
+ /**
807
+ * Reset counter untuk recipient (e.g., on successful interaction)
808
+ */
809
+ reset(recipientJid: string): void {
810
+ this.counts.delete(recipientJid);
811
+ }
812
+
813
+ /**
814
+ * Cleanup inactive recipients (no sends in last hour)
815
+ */
816
+ cleanup(): void {
817
+ const now = Date.now();
818
+ for (const [jid, entry] of this.counts.entries()) {
819
+ if (now - entry.lastReset.hour > 3600000) {
820
+ this.counts.delete(jid);
821
+ }
822
+ }
823
+ }
824
+
825
+ getStats(): {
826
+ trackedRecipients: number;
827
+ limits: { perSecond: number; perMinute: number; perHour: number };
828
+ } {
829
+ return {
830
+ trackedRecipients: this.counts.size,
831
+ limits: { perSecond: this.limits.perSecond, perMinute: this.limits.perMinute, perHour: this.limits.perHour }
832
+ };
833
+ }
834
+ }
835
+
836
+ // ============================================================
837
+ // FEATURE: Message Persistence (survive restarts)
838
+ // ============================================================
839
+
840
+ /**
841
+ * MessagePersistence - persist outgoing messages untuk survive restarts.
842
+ * Fix Baileys issue: pending messages lost on restart.
843
+ */
844
+ export interface PersistedMessage {
845
+ id: string;
846
+ recipientJid: string;
847
+ content: any;
848
+ status: 'pending' | 'sent' | 'failed';
849
+ attempts: number;
850
+ createdAt: number;
851
+ updatedAt: number;
852
+ lastError?: string;
853
+ }
854
+
855
+ export class MessagePersistence {
856
+ private messages: Map<string, PersistedMessage> = new Map();
857
+ private maxRetention: number;
858
+ private logger: WALogger;
859
+
860
+ constructor(opts: { maxRetention?: number; logger?: WALogger } = {}) {
861
+ this.maxRetention = opts.maxRetention ?? 10000; // Keep last 10k messages
862
+ this.logger = opts.logger ?? new SilentLogger();
863
+ }
864
+
865
+ save(message: PersistedMessage): void {
866
+ this.messages.set(message.id, message);
867
+ // Trim if over max
868
+ if (this.messages.size > this.maxRetention) {
869
+ const firstKey = this.messages.keys().next().value;
870
+ if (firstKey) this.messages.delete(firstKey);
871
+ }
872
+ }
873
+
874
+ get(id: string): PersistedMessage | undefined {
875
+ return this.messages.get(id);
876
+ }
877
+
878
+ update(id: string, update: Partial<PersistedMessage>): void {
879
+ const existing = this.messages.get(id);
880
+ if (existing) {
881
+ this.messages.set(id, { ...existing, ...update, updatedAt: Date.now() });
882
+ }
883
+ }
884
+
885
+ /**
886
+ * Get all pending messages (untuk retry on restart)
887
+ */
888
+ getPending(): PersistedMessage[] {
889
+ return Array.from(this.messages.values()).filter(m => m.status === 'pending');
890
+ }
891
+
892
+ /**
893
+ * Get all failed messages (untuk manual retry)
894
+ */
895
+ getFailed(): PersistedMessage[] {
896
+ return Array.from(this.messages.values()).filter(m => m.status === 'failed');
897
+ }
898
+
899
+ delete(id: string): void {
900
+ this.messages.delete(id);
901
+ }
902
+
903
+ /**
904
+ * Cleanup old messages (older than X ms)
905
+ */
906
+ cleanup(maxAgeMs: number = 24 * 60 * 60 * 1000): number {
907
+ const cutoff = Date.now() - maxAgeMs;
908
+ let cleaned = 0;
909
+ for (const [id, msg] of this.messages.entries()) {
910
+ if (msg.updatedAt < cutoff) {
911
+ this.messages.delete(id);
912
+ cleaned++;
913
+ }
914
+ }
915
+ return cleaned;
916
+ }
917
+
918
+ size(): number {
919
+ return this.messages.size;
920
+ }
921
+ }