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