@raevon/n8n-nodes-whatsapp 1.0.0 → 1.0.2
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.
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.WhatsAppApi = void 0;
|
|
4
|
+
// Note: WhatsApp uses WebSocket (not HTTP), so n8n's ICredentialTestRequest
|
|
5
|
+
// can't test the connection. Session validation happens at socket connection
|
|
6
|
+
// time in ensureConnected(). If the session is invalid, the first node
|
|
7
|
+
// execution will trigger QR login automatically.
|
|
4
8
|
class WhatsAppApi {
|
|
5
9
|
constructor() {
|
|
6
10
|
this.name = 'whatsappApi';
|
|
@@ -51,7 +51,17 @@ const node_http_1 = __importDefault(require("node:http"));
|
|
|
51
51
|
const node_url_1 = require("node:url");
|
|
52
52
|
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
|
53
53
|
const randBetween = (min, max) => max > min ? min + Math.floor(Math.random() * (max - min + 1)) : min;
|
|
54
|
+
// I8: Extracted ~ path expansion to avoid duplication
|
|
55
|
+
function expandHome(p) {
|
|
56
|
+
if (!p.startsWith('~'))
|
|
57
|
+
return p;
|
|
58
|
+
return node_path_1.default.join(process.env.HOME || process.env.USERPROFILE || '', p.slice(1));
|
|
59
|
+
}
|
|
54
60
|
// --- Singleton socket manager ---
|
|
61
|
+
// NOTE: This singleton is shared across ALL n8n workflow executions using the same credential.
|
|
62
|
+
// Anti-ban counters (sentTodayCount, sentInBurst, nextSendAt) are module-level — two workflows
|
|
63
|
+
// burning through the same daily limit will share the counter. This is intentional for a
|
|
64
|
+
// single-credential setup. Multi-credential support would require a Map<sessionPath, State>.
|
|
55
65
|
let socketInstance = null;
|
|
56
66
|
let socketStatus = 'stopped';
|
|
57
67
|
let socketConfig = null;
|
|
@@ -65,7 +75,9 @@ let reconnectAttempts = 0;
|
|
|
65
75
|
let reconnectTimer = null;
|
|
66
76
|
let qrResolve = null;
|
|
67
77
|
let qrHttpServer = null;
|
|
78
|
+
let qrTimeout = null; // C3: Store timeout ref for cleanup
|
|
68
79
|
let latestQr = null;
|
|
80
|
+
let generation = 0; // #10: Generation counter — prevents stale handlers on reconnect
|
|
69
81
|
function todayStartIso() {
|
|
70
82
|
return new Date().toISOString().slice(0, 10) + 'T00:00:00.000Z';
|
|
71
83
|
}
|
|
@@ -115,6 +127,13 @@ function stopQrServer() {
|
|
|
115
127
|
qrHttpServer = null;
|
|
116
128
|
}
|
|
117
129
|
}
|
|
130
|
+
function clearQrTimeout() {
|
|
131
|
+
if (qrTimeout) {
|
|
132
|
+
clearTimeout(qrTimeout);
|
|
133
|
+
qrTimeout = null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
// #7: Reconnect backoff with jitter — exponential 1s→60s, ±20% randomization
|
|
118
137
|
async function scheduleReconnect(cfg) {
|
|
119
138
|
if (reconnectTimer)
|
|
120
139
|
return;
|
|
@@ -129,9 +148,8 @@ async function scheduleReconnect(cfg) {
|
|
|
129
148
|
async function initSocket(cfg, authPath) {
|
|
130
149
|
if (socketInstance && socketStatus === 'connected')
|
|
131
150
|
return socketInstance;
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
: authPath;
|
|
151
|
+
const gen = ++generation; // #10: Snapshot generation for this connection attempt
|
|
152
|
+
const resolvedPath = expandHome(authPath);
|
|
135
153
|
if (!node_fs_1.default.existsSync(resolvedPath)) {
|
|
136
154
|
node_fs_1.default.mkdirSync(resolvedPath, { recursive: true });
|
|
137
155
|
}
|
|
@@ -139,11 +157,13 @@ async function initSocket(cfg, authPath) {
|
|
|
139
157
|
const { version } = await (0, baileys_1.fetchLatestBaileysVersion)();
|
|
140
158
|
const sock = (0, baileys_1.default)({
|
|
141
159
|
version,
|
|
160
|
+
// #12: Browser fingerprint — appears as Ubuntu Firefox, not a bot
|
|
142
161
|
browser: baileys_1.Browsers.ubuntu('n8n WhatsApp Node'),
|
|
143
162
|
auth: {
|
|
144
163
|
creds: state.creds,
|
|
145
164
|
keys: (0, baileys_1.makeCacheableSignalKeyStore)(state.keys, { level: 'silent' }),
|
|
146
165
|
},
|
|
166
|
+
// #11: Stealth flags — don't go online, don't sync history, no link previews
|
|
147
167
|
markOnlineOnConnect: false,
|
|
148
168
|
syncFullHistory: false,
|
|
149
169
|
shouldSyncHistoryMessage: () => false,
|
|
@@ -154,16 +174,22 @@ async function initSocket(cfg, authPath) {
|
|
|
154
174
|
socketStatus = 'connecting';
|
|
155
175
|
reconnectAttempts = 0;
|
|
156
176
|
sock.ev.on('creds.update', () => {
|
|
157
|
-
|
|
177
|
+
if (gen !== generation)
|
|
178
|
+
return; // #10: Ignore stale connection events
|
|
179
|
+
saveCreds().catch((err) => {
|
|
180
|
+
console.error('[WhatsApp] Failed to save credentials:', err.message);
|
|
181
|
+
});
|
|
158
182
|
});
|
|
159
183
|
sock.ev.on('connection.update', (update) => {
|
|
160
184
|
var _a, _b;
|
|
185
|
+
if (gen !== generation)
|
|
186
|
+
return; // #10: Ignore stale connection events
|
|
161
187
|
const { connection, lastDisconnect, qr } = update;
|
|
162
188
|
if (qr) {
|
|
163
189
|
latestQr = qr;
|
|
164
190
|
socketStatus = 'qr_ready';
|
|
165
|
-
const qrUrl = `https://quickchart.io/qr?text=${encodeURIComponent(qr)}&size=300`;
|
|
166
191
|
if (qrResolve) {
|
|
192
|
+
const qrUrl = `https://quickchart.io/qr?text=${encodeURIComponent(qr)}&size=300`;
|
|
167
193
|
qrResolve({ qr, qrUrl });
|
|
168
194
|
qrResolve = null;
|
|
169
195
|
}
|
|
@@ -172,6 +198,7 @@ async function initSocket(cfg, authPath) {
|
|
|
172
198
|
socketStatus = 'connected';
|
|
173
199
|
reconnectAttempts = 0;
|
|
174
200
|
latestQr = null;
|
|
201
|
+
clearQrTimeout(); // C3: Clear QR timeout on successful connection
|
|
175
202
|
stopQrServer();
|
|
176
203
|
return;
|
|
177
204
|
}
|
|
@@ -180,6 +207,7 @@ async function initSocket(cfg, authPath) {
|
|
|
180
207
|
if (code === baileys_1.DisconnectReason.loggedOut) {
|
|
181
208
|
socketStatus = 'logged_out';
|
|
182
209
|
socketInstance = null;
|
|
210
|
+
++generation; // #10: Invalidate all handlers from this session
|
|
183
211
|
stopQrServer();
|
|
184
212
|
}
|
|
185
213
|
else {
|
|
@@ -188,6 +216,8 @@ async function initSocket(cfg, authPath) {
|
|
|
188
216
|
}
|
|
189
217
|
}
|
|
190
218
|
});
|
|
219
|
+
// #8: Fire-and-forget — no receipt tracking, no delivery acks, minimal protocol chatter
|
|
220
|
+
// (Baileys doesn't auto-track receipts unless you call readMessages/sendReceipt — we don't)
|
|
191
221
|
return sock;
|
|
192
222
|
}
|
|
193
223
|
// --- Public API ---
|
|
@@ -221,11 +251,12 @@ async function ensureConnected(cfg) {
|
|
|
221
251
|
};
|
|
222
252
|
socketConfig = antiBanCfg;
|
|
223
253
|
sessionPath = cfg.sessionPath;
|
|
224
|
-
queue
|
|
254
|
+
// C1: Guard queue creation — only create once, preserve anti-ban state across calls
|
|
255
|
+
if (!queue) {
|
|
256
|
+
queue = new p_queue_1.default({ concurrency: 1 });
|
|
257
|
+
}
|
|
225
258
|
// Start QR server for first-time setup
|
|
226
|
-
const resolvedPath = cfg.sessionPath
|
|
227
|
-
? node_path_1.default.join(process.env.HOME || process.env.USERPROFILE || '', cfg.sessionPath.slice(1))
|
|
228
|
-
: cfg.sessionPath;
|
|
259
|
+
const resolvedPath = expandHome(cfg.sessionPath);
|
|
229
260
|
const hasSession = node_fs_1.default.existsSync(resolvedPath) && node_fs_1.default.readdirSync(resolvedPath).length > 0;
|
|
230
261
|
if (!hasSession) {
|
|
231
262
|
await startQrServer(cfg.qrPort);
|
|
@@ -235,12 +266,13 @@ async function ensureConnected(cfg) {
|
|
|
235
266
|
if (!hasSession) {
|
|
236
267
|
const qrData = await new Promise((resolve) => {
|
|
237
268
|
qrResolve = resolve;
|
|
238
|
-
//
|
|
239
|
-
setTimeout(() => {
|
|
269
|
+
// C3: Store timeout ref so it can be cleared on successful scan
|
|
270
|
+
qrTimeout = setTimeout(() => {
|
|
240
271
|
if (qrResolve) {
|
|
241
272
|
qrResolve(null);
|
|
242
273
|
qrResolve = null;
|
|
243
274
|
}
|
|
275
|
+
qrTimeout = null;
|
|
244
276
|
}, 300000);
|
|
245
277
|
});
|
|
246
278
|
if (!qrData) {
|
|
@@ -296,7 +328,7 @@ async function sendMessageWithAntiBan(to, content, cfg) {
|
|
|
296
328
|
}
|
|
297
329
|
}
|
|
298
330
|
const jid = normalizeRecipient(to);
|
|
299
|
-
// Recipient validation
|
|
331
|
+
// Recipient validation (#6)
|
|
300
332
|
if (cfg.checkRecipientExists && !jid.endsWith('@g.us')) {
|
|
301
333
|
const results = await sock.onWhatsApp(jid);
|
|
302
334
|
const result = results === null || results === void 0 ? void 0 : results[0];
|
|
@@ -317,12 +349,13 @@ async function sendMessageWithAntiBan(to, content, cfg) {
|
|
|
317
349
|
dailySendLimit: cfg.dailySendLimit,
|
|
318
350
|
checkRecipientExists: cfg.checkRecipientExists,
|
|
319
351
|
};
|
|
320
|
-
|
|
352
|
+
// #9: HTTP timeout race — if anti-ban delays push past 15s, return queued
|
|
353
|
+
const task = queue.add(async () => {
|
|
321
354
|
var _a;
|
|
322
355
|
const wait = nextSendAt - Date.now();
|
|
323
356
|
if (wait > 0)
|
|
324
357
|
await sleep(wait);
|
|
325
|
-
// Typing simulation
|
|
358
|
+
// Typing simulation (#4)
|
|
326
359
|
await simulateTyping(jid, content, antiBanCfg);
|
|
327
360
|
// Build message content
|
|
328
361
|
const msgContent = {};
|
|
@@ -345,9 +378,9 @@ async function sendMessageWithAntiBan(to, content, cfg) {
|
|
|
345
378
|
if (content.ptt !== undefined)
|
|
346
379
|
msgContent.ptt = content.ptt;
|
|
347
380
|
const response = await sock.sendMessage(jid, msgContent);
|
|
348
|
-
// Update anti-ban timing
|
|
381
|
+
// Update anti-ban timing (#2, #3)
|
|
349
382
|
nextSendAt = Date.now() + sendGapMs(antiBanCfg) + burstPauseMs(antiBanCfg);
|
|
350
|
-
// Update daily count
|
|
383
|
+
// Update daily count (#5)
|
|
351
384
|
if (cfg.dailySendLimit > 0)
|
|
352
385
|
sentTodayCount++;
|
|
353
386
|
if (!((_a = response === null || response === void 0 ? void 0 : response.key) === null || _a === void 0 ? void 0 : _a.id)) {
|
|
@@ -359,7 +392,23 @@ async function sendMessageWithAntiBan(to, content, cfg) {
|
|
|
359
392
|
recipient: jid,
|
|
360
393
|
};
|
|
361
394
|
});
|
|
362
|
-
|
|
395
|
+
// #9: HTTP timeout race — hand off to background if anti-ban delay exceeds 15s
|
|
396
|
+
let timer;
|
|
397
|
+
const winner = await Promise.race([
|
|
398
|
+
task.then(result => ({ result }), error => ({ error })),
|
|
399
|
+
new Promise(resolve => { timer = setTimeout(() => resolve(null), 15000); }),
|
|
400
|
+
]);
|
|
401
|
+
clearTimeout(timer);
|
|
402
|
+
if (!winner) {
|
|
403
|
+
// Queued behind anti-ban delays — let it send in background
|
|
404
|
+
task.then(() => { }, () => { });
|
|
405
|
+
return { messageId: 'queued', status: 'queued', recipient: jid };
|
|
406
|
+
}
|
|
407
|
+
const w = winner;
|
|
408
|
+
if (w.error) {
|
|
409
|
+
throw w.error;
|
|
410
|
+
}
|
|
411
|
+
return w.result;
|
|
363
412
|
}
|
|
364
413
|
function getConnectionStatus() {
|
|
365
414
|
var _a;
|
|
@@ -373,7 +422,7 @@ function getConnectionStatus() {
|
|
|
373
422
|
};
|
|
374
423
|
}
|
|
375
424
|
function parseIncomingMessage(msg) {
|
|
376
|
-
var _a, _b, _c, _d, _e, _f, _g;
|
|
425
|
+
var _a, _b, _c, _d, _e, _f, _g, _h;
|
|
377
426
|
if (!msg.message || !msg.key || !msg.key.remoteJid)
|
|
378
427
|
return null;
|
|
379
428
|
let content = null;
|
|
@@ -414,10 +463,11 @@ function parseIncomingMessage(msg) {
|
|
|
414
463
|
return {
|
|
415
464
|
messageId: msg.key.id,
|
|
416
465
|
chatJid: msg.key.remoteJid,
|
|
417
|
-
|
|
466
|
+
// C4: Simplified sender logic — participant is sender in groups, remoteJid in DMs
|
|
467
|
+
sender: (_g = msg.key.participant) !== null && _g !== void 0 ? _g : msg.key.remoteJid,
|
|
418
468
|
content: content || `[${messageType}]`,
|
|
419
469
|
timestamp: new Date(timestampSeconds * 1000).toISOString(),
|
|
420
|
-
isFromMe: (
|
|
470
|
+
isFromMe: (_h = msg.key.fromMe) !== null && _h !== void 0 ? _h : false,
|
|
421
471
|
messageType,
|
|
422
472
|
};
|
|
423
473
|
}
|
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.WhatsAppTrigger = void 0;
|
|
4
4
|
const WhatsAppApiHelper_1 = require("./WhatsAppApiHelper");
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
// Per-credential buffers — one buffer per session path
|
|
6
|
+
// This ensures multiple trigger nodes with different credentials don't share messages
|
|
7
|
+
const buffersBySession = new Map();
|
|
8
|
+
const initializedBySession = new Set();
|
|
7
9
|
class WhatsAppTrigger {
|
|
8
10
|
constructor() {
|
|
9
11
|
this.description = {
|
|
@@ -51,8 +53,14 @@ class WhatsAppTrigger {
|
|
|
51
53
|
const cfg = await (0, WhatsAppApiHelper_1.getWhatsAppCredentials)(credentials);
|
|
52
54
|
const chatFilter = this.getNodeParameter('chatJidFilter', '');
|
|
53
55
|
const onlyText = this.getNodeParameter('onlyText', false);
|
|
54
|
-
|
|
55
|
-
|
|
56
|
+
const sessionKey = cfg.sessionPath;
|
|
57
|
+
// Get or create buffer for this credential
|
|
58
|
+
if (!buffersBySession.has(sessionKey)) {
|
|
59
|
+
buffersBySession.set(sessionKey, []);
|
|
60
|
+
}
|
|
61
|
+
const buffer = buffersBySession.get(sessionKey);
|
|
62
|
+
// Initialize socket listener once per credential
|
|
63
|
+
if (!initializedBySession.has(sessionKey)) {
|
|
56
64
|
const sock = await (0, WhatsAppApiHelper_1.ensureConnected)(cfg);
|
|
57
65
|
sock.ev.on('messages.upsert', (upsert) => {
|
|
58
66
|
const { messages, type } = upsert;
|
|
@@ -68,15 +76,15 @@ class WhatsAppTrigger {
|
|
|
68
76
|
continue;
|
|
69
77
|
if (onlyText && parsed.messageType !== 'conversation' && parsed.messageType !== 'extendedTextMessage')
|
|
70
78
|
continue;
|
|
71
|
-
|
|
79
|
+
buffer.push(parsed);
|
|
72
80
|
}
|
|
73
81
|
});
|
|
74
|
-
|
|
82
|
+
initializedBySession.add(sessionKey);
|
|
75
83
|
}
|
|
76
84
|
// Return buffered messages
|
|
77
|
-
if (
|
|
85
|
+
if (buffer.length === 0)
|
|
78
86
|
return null;
|
|
79
|
-
const messages =
|
|
87
|
+
const messages = buffer.splice(0, buffer.length);
|
|
80
88
|
return [messages.map((msg) => ({
|
|
81
89
|
json: msg,
|
|
82
90
|
pairedItem: { item: 0 },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@raevon/n8n-nodes-whatsapp",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"description": "n8n community node for WhatsApp — send and receive messages with anti-ban protection via the Baileys library",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"n8n-community-node-package",
|
|
@@ -35,9 +35,7 @@
|
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
37
|
"@whiskeysockets/baileys": "^7.0.0-rc13",
|
|
38
|
-
"p-queue": "^8.0.1"
|
|
39
|
-
"pino": "^9.6.0",
|
|
40
|
-
"open": "^10.1.0"
|
|
38
|
+
"p-queue": "^8.0.1"
|
|
41
39
|
},
|
|
42
40
|
"devDependencies": {
|
|
43
41
|
"@types/node": "^20.19.39",
|