@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 resolvedPath = authPath.startsWith('~')
133
- ? node_path_1.default.join(process.env.HOME || process.env.USERPROFILE || '', authPath.slice(1))
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
- saveCreds().catch(() => { });
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 = new p_queue_1.default({ concurrency: 1 });
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.startsWith('~')
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
- // Timeout after 5 minutes
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
- const result = await queue.add(async () => {
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
- return result;
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
- sender: msg.key.participant || (msg.key.remoteJid !== msg.key.participant ? msg.key.remoteJid : null),
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: (_g = msg.key.fromMe) !== null && _g !== void 0 ? _g : false,
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
- const messageBuffer = [];
6
- let socketInitialized = false;
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
- // Initialize socket listener once
55
- if (!socketInitialized) {
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
- messageBuffer.push(parsed);
79
+ buffer.push(parsed);
72
80
  }
73
81
  });
74
- socketInitialized = true;
82
+ initializedBySession.add(sessionKey);
75
83
  }
76
84
  // Return buffered messages
77
- if (messageBuffer.length === 0)
85
+ if (buffer.length === 0)
78
86
  return null;
79
- const messages = messageBuffer.splice(0, messageBuffer.length);
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.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",