whatsapp_notifier 0.8.1 → 0.9.1
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.
- checksums.yaml +4 -4
- data/README.md +391 -102
- data/docs/CONFIGURATION.md +89 -0
- data/lib/whatsapp_notifier/client.rb +4 -0
- data/lib/whatsapp_notifier/error_code.rb +137 -0
- data/lib/whatsapp_notifier/errors.rb +21 -0
- data/lib/whatsapp_notifier/providers/base.rb +18 -0
- data/lib/whatsapp_notifier/providers/web_automation.rb +14 -3
- data/lib/whatsapp_notifier/services/web_automation/history.test.ts +68 -1
- data/lib/whatsapp_notifier/services/web_automation/history.ts +34 -7
- data/lib/whatsapp_notifier/services/web_automation/inbound.test.ts +120 -6
- data/lib/whatsapp_notifier/services/web_automation/inbound.ts +115 -16
- data/lib/whatsapp_notifier/services/web_automation/index.ts +59 -0
- data/lib/whatsapp_notifier/services/web_automation/metrics.test.ts +3 -0
- data/lib/whatsapp_notifier/services/web_automation/metrics.ts +3 -0
- data/lib/whatsapp_notifier/services/web_automation/sessions.test.ts +22 -0
- data/lib/whatsapp_notifier/services/web_automation/sessions.ts +19 -0
- data/lib/whatsapp_notifier/version.rb +1 -1
- data/lib/whatsapp_notifier/web_adapter.rb +6 -2
- data/lib/whatsapp_notifier.rb +11 -0
- data/spec/error_code_spec.rb +107 -0
- data/spec/providers/web_automation_spec.rb +62 -0
- data/spec/session_ready_spec.rb +114 -0
- data/spec/web_adapter_spec.rb +35 -0
- metadata +5 -1
|
@@ -54,6 +54,13 @@ export const INBOUND_QUEUE_CAP = 1000;
|
|
|
54
54
|
|
|
55
55
|
const inboundQueues = new Map<string, InboundMsg[]>();
|
|
56
56
|
const outboundTargets = new Map<string, Set<string>>();
|
|
57
|
+
// userId → (@lid privacy id → resolved phone @c.us). Learned whenever an @lid
|
|
58
|
+
// is resolved to a phone (inbound contact lookup, fromMe live lookup, history
|
|
59
|
+
// replay) and consulted by the fromMe leg, where msg.getContact() is no help
|
|
60
|
+
// (it resolves the SENDER — the operator). Persisted per user next to
|
|
61
|
+
// outbound_targets.json so a restart doesn't resume dropping operator
|
|
62
|
+
// messages until the customer happens to write again.
|
|
63
|
+
const lidAliases = new Map<string, Map<string, string>>();
|
|
57
64
|
|
|
58
65
|
// How to resolve a user's on-disk session dir. index.ts wires this to
|
|
59
66
|
// sessionDirForUser; tests point it at a tmp dir.
|
|
@@ -66,6 +73,34 @@ function targetsFilePath(userId: string) {
|
|
|
66
73
|
return join(baseDirResolver(userId), 'outbound_targets.json');
|
|
67
74
|
}
|
|
68
75
|
|
|
76
|
+
function aliasesFilePath(userId: string) {
|
|
77
|
+
return join(baseDirResolver(userId), 'lid_aliases.json');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function loadLidAliases(userId: string): Map<string, string> {
|
|
81
|
+
const cached = lidAliases.get(userId);
|
|
82
|
+
if (cached) return cached;
|
|
83
|
+
|
|
84
|
+
const map = new Map<string, string>();
|
|
85
|
+
try {
|
|
86
|
+
const p = aliasesFilePath(userId);
|
|
87
|
+
if (existsSync(p)) {
|
|
88
|
+
const obj = JSON.parse(readFileSync(p, 'utf8'));
|
|
89
|
+
if (obj && typeof obj === 'object' && !Array.isArray(obj)) {
|
|
90
|
+
for (const [lid, phone] of Object.entries(obj)) {
|
|
91
|
+
if (typeof phone === 'string') map.set(lid, phone);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
} catch (_) { /* corrupt/missing file → start empty */ }
|
|
96
|
+
lidAliases.set(userId, map);
|
|
97
|
+
return map;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function resolveLidAlias(userId: string, lid: string): string | undefined {
|
|
101
|
+
return loadLidAliases(userId).get(lid);
|
|
102
|
+
}
|
|
103
|
+
|
|
69
104
|
export function loadTargets(userId: string): Set<string> {
|
|
70
105
|
const cached = outboundTargets.get(userId);
|
|
71
106
|
if (cached) return cached;
|
|
@@ -82,15 +117,31 @@ export function loadTargets(userId: string): Set<string> {
|
|
|
82
117
|
return set;
|
|
83
118
|
}
|
|
84
119
|
|
|
85
|
-
// After an @lid
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
120
|
+
// After an @lid is resolved to a phone @c.us, two things are remembered:
|
|
121
|
+
//
|
|
122
|
+
// 1. The @lid → phone mapping itself (persisted, see lidAliases): the fromMe
|
|
123
|
+
// leg has no contact handle of its own, so without this map every operator
|
|
124
|
+
// message in an @lid-keyed chat would depend on a live lookup succeeding.
|
|
125
|
+
// 2. If the resolved phone is one of our outbound targets, the @lid joins the
|
|
126
|
+
// allowlist too. rememberTarget at send time only ever stores @c.us ids,
|
|
127
|
+
// but for privacy-number accounts the *chat* is keyed by the @lid — so a
|
|
128
|
+
// reconnect backfill that replays targets via chat-id lookup could never
|
|
129
|
+
// re-open that chat, permanently losing any disconnect-window replies.
|
|
130
|
+
// Duplicate captures across the @c.us/@lid pair are fine: the contract is
|
|
131
|
+
// at-least-once and the host dedupes on messageId.
|
|
92
132
|
export function rememberLidAlias(userId: string, rawFrom: string, resolvedFrom: string) {
|
|
93
133
|
if (!rawFrom.endsWith('@lid')) return;
|
|
134
|
+
const aliases = loadLidAliases(userId);
|
|
135
|
+
if (aliases.get(rawFrom) !== resolvedFrom) {
|
|
136
|
+
aliases.set(rawFrom, resolvedFrom);
|
|
137
|
+
try {
|
|
138
|
+
const dir = baseDirResolver(userId);
|
|
139
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
140
|
+
writeFileSync(aliasesFilePath(userId), JSON.stringify(Object.fromEntries(aliases)));
|
|
141
|
+
} catch (e) {
|
|
142
|
+
console.error(`Failed to persist @lid alias for ${userId}`, e);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
94
145
|
if (loadTargets(userId).has(resolvedFrom)) rememberTarget(userId, rawFrom);
|
|
95
146
|
}
|
|
96
147
|
|
|
@@ -300,15 +351,23 @@ async function processOwnMessage(userId: string, msg: any, deps: CaptureDeps) {
|
|
|
300
351
|
const messageId = msg.id && msg.id._serialized;
|
|
301
352
|
if (messageId && isSelfSend(userId, messageId)) return;
|
|
302
353
|
|
|
303
|
-
const
|
|
354
|
+
const rawTo: string = msg.to || '';
|
|
355
|
+
let to = rawTo;
|
|
304
356
|
// An @lid counterparty carries no phone number the host can thread on,
|
|
305
|
-
// and
|
|
306
|
-
//
|
|
307
|
-
//
|
|
308
|
-
//
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
357
|
+
// and msg.getContact() is no help here (it resolves the SENDER — the
|
|
358
|
+
// operator). Newer WhatsApp keys some 1:1 chats by the @lid, so EVERY
|
|
359
|
+
// operator-phone message in such a chat used to hit an unconditional
|
|
360
|
+
// drop while the customer's side flowed — half the conversation lost.
|
|
361
|
+
// Resolve it instead, BEFORE any media download (a dropped message must
|
|
362
|
+
// not have cost one): the alias map the inbound leg learns first, then
|
|
363
|
+
// one guarded live lookup. Only when both fail is the message dropped —
|
|
364
|
+
// now the rare case, not every @lid-keyed chat.
|
|
365
|
+
if (rawTo.endsWith('@lid')) {
|
|
366
|
+
to = resolveLidAlias(userId, rawTo) || await resolveOwnLidCounterparty(userId, rawTo, msg) || '';
|
|
367
|
+
if (!to) {
|
|
368
|
+
console.log(`Dropping fromMe message to unresolved @lid chat for ${userId}`);
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
312
371
|
}
|
|
313
372
|
|
|
314
373
|
// Same kept-message-earns-the-download rule as inbound: every resolver
|
|
@@ -319,17 +378,52 @@ async function processOwnMessage(userId: string, msg: any, deps: CaptureDeps) {
|
|
|
319
378
|
}
|
|
320
379
|
|
|
321
380
|
const inbound = normalizeInbound(msg, media);
|
|
381
|
+
// Thread on the resolved phone: the host matches `to` against its own
|
|
382
|
+
// recipient records, and a raw @lid would never match. The @lid is still
|
|
383
|
+
// fine as normalizeInbound's fallback-id key — any stable counterparty
|
|
384
|
+
// string dedupes correctly.
|
|
385
|
+
inbound.to = to;
|
|
322
386
|
|
|
323
387
|
// A fromMe message to a brand-new number means the operator opened the
|
|
324
388
|
// conversation in the WhatsApp app — allowlist the chat exactly like
|
|
325
389
|
// /send does for its recipients, so the reconnect backfill can replay
|
|
326
|
-
// this conversation after a disconnect window too.
|
|
390
|
+
// this conversation after a disconnect window too. For an @lid-keyed
|
|
391
|
+
// chat the alias call ALSO allowlists the @lid id itself — the id the
|
|
392
|
+
// chat is actually reachable by (see rememberLidAlias).
|
|
327
393
|
rememberTarget(userId, to);
|
|
394
|
+
if (rawTo.endsWith('@lid')) rememberLidAlias(userId, rawTo, to);
|
|
328
395
|
|
|
329
396
|
enqueueInbound(userId, inbound);
|
|
330
397
|
if (deps.push) deps.push(userId, inbound);
|
|
331
398
|
}
|
|
332
399
|
|
|
400
|
+
// Live fallback for a fromMe @lid counterparty with no learned alias yet
|
|
401
|
+
// (e.g. right after a restart, before the customer writes again). Every wwebjs
|
|
402
|
+
// Message carries its Client, and client.getContactById(<@lid>) is the exact
|
|
403
|
+
// call the inbound leg's msg.getContact() makes for @lid senders — the one
|
|
404
|
+
// path proven to yield the real phone in production. One roundtrip, guarded;
|
|
405
|
+
// a hit is fed into rememberLidAlias so the next fromMe message resolves from
|
|
406
|
+
// the map. contact.id.user is trusted only when the contact id itself is
|
|
407
|
+
// phone-keyed — an @lid contact id would otherwise mint a bogus "phone" out
|
|
408
|
+
// of the privacy id's own digits.
|
|
409
|
+
async function resolveOwnLidCounterparty(userId: string, rawTo: string, msg: any): Promise<string | null> {
|
|
410
|
+
try {
|
|
411
|
+
const client = msg.client;
|
|
412
|
+
if (!client || typeof client.getContactById !== 'function') return null;
|
|
413
|
+
const contact = await client.getContactById(rawTo);
|
|
414
|
+
const num = contact && (contact.number ||
|
|
415
|
+
(contact.id && String(contact.id._serialized || '').endsWith('@c.us') && contact.id.user));
|
|
416
|
+
const digits = num ? String(num).replace(/\D/g, '') : '';
|
|
417
|
+
if (!digits) return null;
|
|
418
|
+
const resolved = `${digits}@c.us`;
|
|
419
|
+
rememberLidAlias(userId, rawTo, resolved);
|
|
420
|
+
return resolved;
|
|
421
|
+
} catch (e) {
|
|
422
|
+
console.error(`fromMe @lid contact lookup failed for ${userId}`, e);
|
|
423
|
+
return null;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
333
427
|
// Minimal slice of whatsapp-web.js Client that backfill needs — a seam so the
|
|
334
428
|
// replay loop can be tested without booting a real client.
|
|
335
429
|
export interface ChatLike {
|
|
@@ -385,6 +479,10 @@ export async function backfillTargets(
|
|
|
385
479
|
export function clearInbound(userId: string) {
|
|
386
480
|
inboundQueues.delete(userId);
|
|
387
481
|
outboundTargets.delete(userId);
|
|
482
|
+
// Same resurrection hazard as the allowlist: the on-disk lid_aliases.json
|
|
483
|
+
// was just wiped with the session dir, and a re-pair (possibly a DIFFERENT
|
|
484
|
+
// WhatsApp number) must not inherit the old pairing's @lid mappings.
|
|
485
|
+
lidAliases.delete(userId);
|
|
388
486
|
// Self-send echo ids belong to the old pairing too — and suppression must
|
|
389
487
|
// never leak across a re-pair (however unlikely an id collision is).
|
|
390
488
|
selfSendIds.delete(userId);
|
|
@@ -394,5 +492,6 @@ export function clearInbound(userId: string) {
|
|
|
394
492
|
export function resetInboundState() {
|
|
395
493
|
inboundQueues.clear();
|
|
396
494
|
outboundTargets.clear();
|
|
495
|
+
lidAliases.clear();
|
|
397
496
|
selfSendIds.clear();
|
|
398
497
|
}
|
|
@@ -8,6 +8,7 @@ import { InitGate } from './init_gate';
|
|
|
8
8
|
import {
|
|
9
9
|
hasPairedSession,
|
|
10
10
|
InitRetryLimiter,
|
|
11
|
+
isReadyWedged,
|
|
11
12
|
reapLimitMs,
|
|
12
13
|
touchClient,
|
|
13
14
|
shouldWipeSessionOnReap
|
|
@@ -43,6 +44,13 @@ const BROWSER_EXECUTABLE_PATH = process.env.PUPPETEER_EXECUTABLE_PATH;
|
|
|
43
44
|
// WhatsApp Web update breaks the injected store), instead of wedging in
|
|
44
45
|
// INITIALIZING forever with no QR.
|
|
45
46
|
const INIT_TIMEOUT_MS = Number(process.env.WHATSAPP_INIT_TIMEOUT_MS || 90000);
|
|
47
|
+
// Recycle a client that authenticates from the on-disk session but never fires
|
|
48
|
+
// 'ready' (the store never hydrates — RAM pressure, a mid-write SIGKILL, a
|
|
49
|
+
// slow web.whatsapp.com). Without this the client serves qr=null +
|
|
50
|
+
// authenticated=false until the 30-min idle reaper — the "No QR available
|
|
51
|
+
// forever after a deploy" outage. 3 minutes is comfortably above a slow but
|
|
52
|
+
// healthy hydration (~30s observed) and well under the reaper.
|
|
53
|
+
const READY_TIMEOUT_MS = Number(process.env.WHATSAPP_READY_TIMEOUT_MS || 180000);
|
|
46
54
|
// Optionally pin the WhatsApp Web build so a live web.whatsapp.com change can't
|
|
47
55
|
// silently break the client. Set WWEBJS_WEB_VERSION to a known-good version
|
|
48
56
|
// (e.g. "2.3000.1023204887"); leave unset to use the library default.
|
|
@@ -63,6 +71,7 @@ interface ClientData {
|
|
|
63
71
|
// pairing visit whose LocalAuth dir holds no credentials at all.
|
|
64
72
|
everAuthenticated?: boolean;
|
|
65
73
|
initTimer?: ReturnType<typeof setTimeout>;
|
|
74
|
+
readyTimer?: ReturnType<typeof setTimeout>;
|
|
66
75
|
releaseInitSlot?: () => void;
|
|
67
76
|
}
|
|
68
77
|
|
|
@@ -101,6 +110,27 @@ async function pushWebhook(userId: string, msg: InboundMsg) {
|
|
|
101
110
|
}
|
|
102
111
|
}
|
|
103
112
|
|
|
113
|
+
// Session lifecycle push (0.9.1): tell the host the moment a session becomes
|
|
114
|
+
// sendable, so work it parked on "session down" resumes in realtime instead
|
|
115
|
+
// of on its next polling tick. Same URL + token as the message webhook; the
|
|
116
|
+
// payload carries `event` instead of `message`, which pre-0.9.1 hosts reject
|
|
117
|
+
// with a 400 — harmless, and the host's poll-based resume still covers them.
|
|
118
|
+
async function pushSessionEvent(userId: string, event: 'session_ready') {
|
|
119
|
+
if (!WEBHOOK_URL) return;
|
|
120
|
+
try {
|
|
121
|
+
await fetch(WEBHOOK_URL, {
|
|
122
|
+
method: 'POST',
|
|
123
|
+
headers: {
|
|
124
|
+
'Content-Type': 'application/json',
|
|
125
|
+
...(WEBHOOK_TOKEN ? { 'X-WA-Token': WEBHOOK_TOKEN } : {})
|
|
126
|
+
},
|
|
127
|
+
body: JSON.stringify({ userId, event })
|
|
128
|
+
});
|
|
129
|
+
} catch (e) {
|
|
130
|
+
console.error(`Session event push failed for ${userId}`, e);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
104
134
|
// Wrapper around the testable pipeline in inbound.ts (sanity filter → sender
|
|
105
135
|
// resolution with early @lid drop → media download → normalize → enqueue →
|
|
106
136
|
// webhook). The catch keeps a single bad message from killing the listener.
|
|
@@ -140,6 +170,13 @@ const initGate = new InitGate(Number(process.env.WHATSAPP_MAX_CONCURRENT_INITS |
|
|
|
140
170
|
const BROWSER_LAUNCH_TIMEOUT_MS = Number(process.env.WHATSAPP_BROWSER_TIMEOUT_MS || 60000);
|
|
141
171
|
const PROTOCOL_TIMEOUT_MS = Number(process.env.WHATSAPP_PROTOCOL_TIMEOUT_MS || 120000);
|
|
142
172
|
|
|
173
|
+
function clearReadyTimer(clientData: ClientData) {
|
|
174
|
+
if (clientData.readyTimer) {
|
|
175
|
+
clearTimeout(clientData.readyTimer);
|
|
176
|
+
clientData.readyTimer = undefined;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
143
180
|
function clearInitTimer(clientData: ClientData) {
|
|
144
181
|
if (clientData.initTimer) {
|
|
145
182
|
clearTimeout(clientData.initTimer);
|
|
@@ -290,6 +327,7 @@ async function getOrCreateClient(userId: string): Promise<ClientData> {
|
|
|
290
327
|
client.on('qr', async (qr) => {
|
|
291
328
|
clientData.state = 'QR_REQUIRED';
|
|
292
329
|
clearInitTimer(clientData); // progress made — QR is showable
|
|
330
|
+
clearReadyTimer(clientData); // WhatsApp wants a re-scan — a QR IS showing, not a wedge
|
|
293
331
|
try {
|
|
294
332
|
clientData.qr = await toDataURL(qr);
|
|
295
333
|
console.log(`QR RECEIVED and converted for User ${userId}`);
|
|
@@ -303,8 +341,12 @@ async function getOrCreateClient(userId: string): Promise<ClientData> {
|
|
|
303
341
|
clientData.qr = null;
|
|
304
342
|
clientData.ready = true;
|
|
305
343
|
clearInitTimer(clientData);
|
|
344
|
+
clearReadyTimer(clientData);
|
|
306
345
|
initRetries.reset(userId);
|
|
307
346
|
console.log(`Client is READY for User ${userId}`);
|
|
347
|
+
// Wake the host NOW — a campaign parked on "session down" should not
|
|
348
|
+
// wait out a polling interval when the session just came back.
|
|
349
|
+
pushSessionEvent(userId, 'session_ready').catch(console.error);
|
|
308
350
|
// Replay anything that arrived while we were disconnected.
|
|
309
351
|
backfillInbound(userId, client).catch((e) => console.error(`Backfill failed for ${userId}`, e));
|
|
310
352
|
});
|
|
@@ -324,12 +366,28 @@ async function getOrCreateClient(userId: string): Promise<ClientData> {
|
|
|
324
366
|
clientData.everAuthenticated = true;
|
|
325
367
|
clearInitTimer(clientData);
|
|
326
368
|
console.log(`AUTHENTICATED for User ${userId}`);
|
|
369
|
+
|
|
370
|
+
// Ready watchdog: 'authenticated' just cleared the INITIALIZING
|
|
371
|
+
// watchdog, so from here nothing guards the authenticated→ready hop.
|
|
372
|
+
// If the store never hydrates, recycle — the session dir survives
|
|
373
|
+
// (everAuthenticated), so the next /status poll relaunches Chromium
|
|
374
|
+
// and reconnects without a new QR. Re-armed on every 'authenticated',
|
|
375
|
+
// so a client that re-auths after a hiccup gets a fresh window.
|
|
376
|
+
clearReadyTimer(clientData);
|
|
377
|
+
clientData.readyTimer = setTimeout(() => {
|
|
378
|
+
if (isReadyWedged(clientData)) {
|
|
379
|
+
counters.ready_timeouts_total += 1;
|
|
380
|
+
console.error(`User ${userId} AUTHENTICATED but not ready > ${READY_TIMEOUT_MS}ms — recycling (session kept)`);
|
|
381
|
+
destroyClient(userId).catch(console.error);
|
|
382
|
+
}
|
|
383
|
+
}, READY_TIMEOUT_MS);
|
|
327
384
|
});
|
|
328
385
|
|
|
329
386
|
client.on('auth_failure', (msg) => {
|
|
330
387
|
clientData.state = 'DISCONNECTED';
|
|
331
388
|
clientData.ready = false;
|
|
332
389
|
clearInitTimer(clientData);
|
|
390
|
+
clearReadyTimer(clientData);
|
|
333
391
|
counters.auth_failures_total += 1;
|
|
334
392
|
console.error(`AUTHENTICATION FAILURE for User ${userId}`, msg);
|
|
335
393
|
});
|
|
@@ -409,6 +467,7 @@ async function destroyClient(userId: string, clearSession: boolean = false) {
|
|
|
409
467
|
if (data && !data.isDestroying) {
|
|
410
468
|
data.isDestroying = true;
|
|
411
469
|
clearInitTimer(data);
|
|
470
|
+
clearReadyTimer(data);
|
|
412
471
|
console.log(`Destroying WhatsApp client for User: ${userId} (clearSession: ${clearSession})`);
|
|
413
472
|
try {
|
|
414
473
|
// Unregister listeners to prevent loops or double-destroys
|
|
@@ -14,6 +14,7 @@ describe('newCounters', () => {
|
|
|
14
14
|
init_failures_total: 0,
|
|
15
15
|
ws_endpoint_timeouts_total: 0,
|
|
16
16
|
init_timeouts_total: 0,
|
|
17
|
+
ready_timeouts_total: 0,
|
|
17
18
|
auth_failures_total: 0,
|
|
18
19
|
disconnects_total: 0,
|
|
19
20
|
});
|
|
@@ -57,6 +58,7 @@ describe('renderMetrics', () => {
|
|
|
57
58
|
init_failures_total: 4,
|
|
58
59
|
ws_endpoint_timeouts_total: 2,
|
|
59
60
|
init_timeouts_total: 1,
|
|
61
|
+
ready_timeouts_total: 6,
|
|
60
62
|
auth_failures_total: 3,
|
|
61
63
|
disconnects_total: 5,
|
|
62
64
|
};
|
|
@@ -81,6 +83,7 @@ describe('renderMetrics', () => {
|
|
|
81
83
|
expect(out).toContain('whatsapp_init_failures_total 4');
|
|
82
84
|
expect(out).toContain('whatsapp_ws_endpoint_timeouts_total 2');
|
|
83
85
|
expect(out).toContain('whatsapp_init_timeouts_total 1');
|
|
86
|
+
expect(out).toContain('whatsapp_ready_timeouts_total 6');
|
|
84
87
|
expect(out).toContain('whatsapp_auth_failures_total 3');
|
|
85
88
|
expect(out).toContain('whatsapp_disconnects_total 5');
|
|
86
89
|
expect(out).toContain('whatsapp_service_uptime_seconds 86400');
|
|
@@ -19,6 +19,7 @@ export interface Counters {
|
|
|
19
19
|
init_failures_total: number; // client.initialize() rejected
|
|
20
20
|
ws_endpoint_timeouts_total: number; // Chromium never produced a WS endpoint (crash-loop signature)
|
|
21
21
|
init_timeouts_total: number; // watchdog recycled a stuck-INITIALIZING client
|
|
22
|
+
ready_timeouts_total: number; // watchdog recycled an AUTHENTICATED-but-never-ready wedge
|
|
22
23
|
auth_failures_total: number; // auth_failure event
|
|
23
24
|
disconnects_total: number; // disconnected event
|
|
24
25
|
}
|
|
@@ -28,6 +29,7 @@ export function newCounters(): Counters {
|
|
|
28
29
|
init_failures_total: 0,
|
|
29
30
|
ws_endpoint_timeouts_total: 0,
|
|
30
31
|
init_timeouts_total: 0,
|
|
32
|
+
ready_timeouts_total: 0,
|
|
31
33
|
auth_failures_total: 0,
|
|
32
34
|
disconnects_total: 0,
|
|
33
35
|
};
|
|
@@ -103,6 +105,7 @@ export function renderMetrics(
|
|
|
103
105
|
...counter('whatsapp_init_failures_total', 'client.initialize() rejections.', counters.init_failures_total),
|
|
104
106
|
...counter('whatsapp_ws_endpoint_timeouts_total', 'Chromium launches that never produced a WS endpoint (crash-loop signature).', counters.ws_endpoint_timeouts_total),
|
|
105
107
|
...counter('whatsapp_init_timeouts_total', 'Clients recycled by the INITIALIZING watchdog.', counters.init_timeouts_total),
|
|
108
|
+
...counter('whatsapp_ready_timeouts_total', 'Clients recycled by the ready watchdog (AUTHENTICATED but never ready).', counters.ready_timeouts_total),
|
|
106
109
|
...counter('whatsapp_auth_failures_total', 'auth_failure events.', counters.auth_failures_total),
|
|
107
110
|
...counter('whatsapp_disconnects_total', 'disconnected events.', counters.disconnects_total),
|
|
108
111
|
...gauge('whatsapp_service_uptime_seconds', 'Seconds since the service process started.', uptimeSeconds),
|
|
@@ -5,6 +5,7 @@ import { join } from 'path';
|
|
|
5
5
|
import {
|
|
6
6
|
hasPairedSession,
|
|
7
7
|
InitRetryLimiter,
|
|
8
|
+
isReadyWedged,
|
|
8
9
|
reapLimitMs,
|
|
9
10
|
touchClient,
|
|
10
11
|
shouldWipeSessionOnReap
|
|
@@ -127,3 +128,24 @@ describe('InitRetryLimiter', () => {
|
|
|
127
128
|
expect(limiter.shouldRetry('b')).toBe(false);
|
|
128
129
|
});
|
|
129
130
|
});
|
|
131
|
+
|
|
132
|
+
describe('isReadyWedged', () => {
|
|
133
|
+
test('AUTHENTICATED but never ready is the wedge', () => {
|
|
134
|
+
expect(isReadyWedged({ state: 'AUTHENTICATED', ready: false })).toBe(true);
|
|
135
|
+
expect(isReadyWedged({ state: 'AUTHENTICATED' })).toBe(true); // ready never set
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('a ready client is the normal path, not a wedge', () => {
|
|
139
|
+
expect(isReadyWedged({ state: 'AUTHENTICATED', ready: true })).toBe(false);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('a client already being destroyed must not be recycled again', () => {
|
|
143
|
+
expect(isReadyWedged({ state: 'AUTHENTICATED', ready: false, isDestroying: true })).toBe(false);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('other states are owned by their own paths', () => {
|
|
147
|
+
expect(isReadyWedged({ state: 'QR_REQUIRED', ready: false })).toBe(false); // QR is showing
|
|
148
|
+
expect(isReadyWedged({ state: 'DISCONNECTED', ready: false })).toBe(false); // disconnect path
|
|
149
|
+
expect(isReadyWedged({ state: 'INITIALIZING', ready: false })).toBe(false); // init watchdog owns it
|
|
150
|
+
});
|
|
151
|
+
});
|
|
@@ -89,3 +89,22 @@ export class InitRetryLimiter {
|
|
|
89
89
|
this.counts.delete(userId);
|
|
90
90
|
}
|
|
91
91
|
}
|
|
92
|
+
|
|
93
|
+
// The AUTHENTICATED-but-never-ready wedge (incident 2026-06-05, again
|
|
94
|
+
// 2026-07-31): after a service restart a session re-authenticates from disk,
|
|
95
|
+
// but Chromium never finishes loading the WhatsApp Web store, so `ready`
|
|
96
|
+
// never fires. The client then serves qr=null + authenticated=false forever —
|
|
97
|
+
// the broadcast modal shows "No QR available" and sends 503. The INITIALIZING
|
|
98
|
+
// watchdog can't see it (state already moved on), and the idle reaper takes
|
|
99
|
+
// 30+ minutes. This predicate names the wedge for the ready watchdog that
|
|
100
|
+
// index.ts arms when 'authenticated' fires.
|
|
101
|
+
//
|
|
102
|
+
// NOT wedged: already ready (the normal path), already being destroyed
|
|
103
|
+
// (recycling twice double-frees nothing but spams logs), or moved to another
|
|
104
|
+
// state — QR_REQUIRED means WhatsApp wants a re-scan and IS showing a QR, and
|
|
105
|
+
// DISCONNECTED clients are on the disconnect path already.
|
|
106
|
+
export function isReadyWedged(
|
|
107
|
+
client: { state: string; ready?: boolean; isDestroying?: boolean }
|
|
108
|
+
): boolean {
|
|
109
|
+
return client.state === 'AUTHENTICATED' && !client.ready && !client.isDestroying;
|
|
110
|
+
}
|
|
@@ -67,6 +67,10 @@ module WhatsAppNotifier
|
|
|
67
67
|
message_id: response["messageId"] || response["message_id"] ||
|
|
68
68
|
payload[:idempotency_key] || "local-#{Time.now.to_i}",
|
|
69
69
|
session: session,
|
|
70
|
+
# A code the service supplied wins over anything the provider could
|
|
71
|
+
# infer from the message text. Absent on every service ≤ 0.8.x, which
|
|
72
|
+
# is why the provider still classifies as a fallback.
|
|
73
|
+
error_code: ErrorCode.normalize(response["errorCode"] || response["error_code"]),
|
|
70
74
|
error_message: response["error"]
|
|
71
75
|
}
|
|
72
76
|
end
|
|
@@ -109,7 +113,7 @@ module WhatsAppNotifier
|
|
|
109
113
|
user_id = user_id_from(metadata)
|
|
110
114
|
res = binary_get("/media/#{user_id}/#{path_id(message_id)}")
|
|
111
115
|
return nil if res.code.to_s == "404"
|
|
112
|
-
raise "service request failed (#{res.code}): #{res.body}" unless res.is_a?(Net::HTTPSuccess)
|
|
116
|
+
raise ServiceError.new("service request failed (#{res.code}): #{res.body}", status: res.code) unless res.is_a?(Net::HTTPSuccess)
|
|
113
117
|
|
|
114
118
|
body = res.body.to_s
|
|
115
119
|
{
|
|
@@ -283,7 +287,7 @@ module WhatsAppNotifier
|
|
|
283
287
|
# carries no "success" key, so they degrade rather than raise.
|
|
284
288
|
return parsed if allow_404 && res.code.to_s == "404"
|
|
285
289
|
|
|
286
|
-
raise "service request failed (#{res.code}): #{parsed["error"] || res.body}"
|
|
290
|
+
raise ServiceError.new("service request failed (#{res.code}): #{parsed["error"] || res.body}", status: res.code)
|
|
287
291
|
end
|
|
288
292
|
|
|
289
293
|
def parse_body(raw)
|
data/lib/whatsapp_notifier.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
require_relative "whatsapp_notifier/version"
|
|
2
2
|
require_relative "whatsapp_notifier/errors"
|
|
3
|
+
require_relative "whatsapp_notifier/error_code"
|
|
3
4
|
require_relative "whatsapp_notifier/result"
|
|
4
5
|
require_relative "whatsapp_notifier/configuration"
|
|
5
6
|
require_relative "whatsapp_notifier/web_adapter"
|
|
@@ -62,6 +63,16 @@ module WhatsAppNotifier
|
|
|
62
63
|
client.connection_status(provider: provider, metadata: metadata)
|
|
63
64
|
end
|
|
64
65
|
|
|
66
|
+
# One call for "can this operator send right now?" — an authenticated
|
|
67
|
+
# session answers true, everything else (including an unreachable status
|
|
68
|
+
# endpoint) answers false. `user_id:` is sugar for the metadata key every
|
|
69
|
+
# multi-user host passes; both forms work.
|
|
70
|
+
def session_ready?(user_id: nil, provider: nil, metadata: {})
|
|
71
|
+
meta = metadata.to_h
|
|
72
|
+
meta = meta.merge(user_id: user_id) unless user_id.nil?
|
|
73
|
+
client.session_ready?(provider: provider, metadata: meta)
|
|
74
|
+
end
|
|
75
|
+
|
|
65
76
|
def fetch_inbound(provider: nil, metadata: {})
|
|
66
77
|
client.fetch_inbound(provider: provider, metadata: metadata)
|
|
67
78
|
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
require "spec_helper"
|
|
2
|
+
require "net/http"
|
|
3
|
+
|
|
4
|
+
RSpec.describe WhatsAppNotifier::ErrorCode do
|
|
5
|
+
describe ".from_exception" do
|
|
6
|
+
it "classifies a service error by its HTTP status, not its body text" do
|
|
7
|
+
# A 401 body that happens to mention a number must still read as auth.
|
|
8
|
+
error = WhatsAppNotifier::ServiceError.new(
|
|
9
|
+
"service request failed (401): No saved WhatsApp session for this user — pair via QR first",
|
|
10
|
+
status: "401"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
expect(described_class.from_exception(error)).to eq(:auth_required)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
it "classifies a 403 as auth and a 429 as rate limiting" do
|
|
17
|
+
forbidden = WhatsAppNotifier::ServiceError.new("nope", status: 403)
|
|
18
|
+
throttled = WhatsAppNotifier::ServiceError.new("slow down", status: "429")
|
|
19
|
+
|
|
20
|
+
expect(described_class.from_exception(forbidden)).to eq(:auth_required)
|
|
21
|
+
expect(described_class.from_exception(throttled)).to eq(:rate_limited)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# The service 422s a malformed REQUEST (a host bug), never a bad number,
|
|
25
|
+
# so it must NOT be classified as :invalid_phone.
|
|
26
|
+
it "leaves a 422 unclassified" do
|
|
27
|
+
error = WhatsAppNotifier::ServiceError.new("service request failed (422): `to` is required", status: 422)
|
|
28
|
+
|
|
29
|
+
expect(described_class.from_exception(error)).to eq(:delivery_exception)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
it "classifies socket failures as service_unreachable" do
|
|
33
|
+
expect(described_class.from_exception(Errno::ECONNREFUSED.new)).to eq(:service_unreachable)
|
|
34
|
+
expect(described_class.from_exception(SocketError.new("getaddrinfo"))).to eq(:service_unreachable)
|
|
35
|
+
expect(described_class.from_exception(Net::OpenTimeout.new)).to eq(:service_unreachable)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# A read timeout is NOT the same as a connect timeout: the request went
|
|
39
|
+
# out, so the send may well have landed.
|
|
40
|
+
it "classifies a read timeout as :timeout, separate from :service_unreachable" do
|
|
41
|
+
expect(described_class.from_exception(Net::ReadTimeout.new)).to eq(:timeout)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
it "falls back to the message when the class and status say nothing" do
|
|
45
|
+
expect(described_class.from_exception(RuntimeError.new("No LID for 919999000001"))).to eq(:recipient_unresolved)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
it "falls back to the message for a 500 carrying a library error" do
|
|
49
|
+
error = WhatsAppNotifier::ServiceError.new("service request failed (500): No LID for 919999000001", status: 500)
|
|
50
|
+
|
|
51
|
+
expect(described_class.from_exception(error)).to eq(:recipient_unresolved)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
it "leaves an opaque exception unclassified" do
|
|
55
|
+
expect(described_class.from_exception(RuntimeError.new("Evaluation failed: e"))).to eq(:delivery_exception)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
describe ".from_message" do
|
|
60
|
+
{
|
|
61
|
+
"No LID for 919999000001" => :recipient_unresolved,
|
|
62
|
+
"Phone number is not registered" => :not_on_whatsapp,
|
|
63
|
+
"wid error: invalid wid" => :invalid_phone,
|
|
64
|
+
"User not authenticated" => :auth_required,
|
|
65
|
+
"Connection refused - connect(2) for 127.0.0.1:3001" => :service_unreachable,
|
|
66
|
+
"execution expired" => :timeout,
|
|
67
|
+
"Too Many Requests" => :rate_limited
|
|
68
|
+
}.each do |text, code|
|
|
69
|
+
it "reads #{text.inspect} as #{code}" do
|
|
70
|
+
expect(described_class.from_message(text)).to eq(code)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
it "returns the catch-all for text it cannot place" do
|
|
75
|
+
expect(described_class.from_message("something new")).to eq(:delivery_exception)
|
|
76
|
+
expect(described_class.from_message(nil)).to eq(:delivery_exception)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
describe ".normalize" do
|
|
81
|
+
it "folds the service's synonyms into one code" do
|
|
82
|
+
expect(described_class.normalize("unauthenticated")).to eq(:auth_required)
|
|
83
|
+
expect(described_class.normalize("NUMBER_NOT_REGISTERED")).to eq(:not_on_whatsapp)
|
|
84
|
+
expect(described_class.normalize(:throttled)).to eq(:rate_limited)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
it "returns nil for a blank code so callers fall back to their own guess" do
|
|
88
|
+
expect(described_class.normalize(nil)).to be_nil
|
|
89
|
+
expect(described_class.normalize(" ")).to be_nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# A newer service may introduce a code before the gem knows it — pass it
|
|
93
|
+
# through rather than flattening it, but only if it looks like an
|
|
94
|
+
# identifier.
|
|
95
|
+
it "passes an unknown identifier-shaped code through" do
|
|
96
|
+
expect(described_class.normalize("queue_full")).to eq(:queue_full)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
it "flattens code-shaped garbage to the catch-all" do
|
|
100
|
+
expect(described_class.normalize("<html>502 Bad Gateway</html>")).to eq(:delivery_exception)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
it "lists every code it can return" do
|
|
105
|
+
expect(described_class::ALL).to include(described_class::UNCLASSIFIED)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -69,6 +69,68 @@ RSpec.describe WhatsAppNotifier::Providers::WebAutomation do
|
|
|
69
69
|
end
|
|
70
70
|
end
|
|
71
71
|
|
|
72
|
+
# Error codes are what hosts branch on to decide whether a retry could
|
|
73
|
+
# double-message someone, so a raised exception must survive as a code and
|
|
74
|
+
# not only as text.
|
|
75
|
+
it "classifies the raised exception into an error code" do
|
|
76
|
+
Dir.mktmpdir do |dir|
|
|
77
|
+
adapter = double
|
|
78
|
+
allow(adapter).to receive(:send_message)
|
|
79
|
+
.and_raise(WhatsAppNotifier::ServiceError.new("service request failed (401): User not authenticated", status: 401))
|
|
80
|
+
allow(adapter).to receive(:fetch_qr_code).and_return("qr")
|
|
81
|
+
allow(adapter).to receive(:connection_status).and_return({})
|
|
82
|
+
config = build_config(path: File.join(dir, "session.json"), adapter: adapter)
|
|
83
|
+
provider = described_class.new(configuration: config)
|
|
84
|
+
|
|
85
|
+
result = provider.deliver(to: "+1", body: "h")
|
|
86
|
+
|
|
87
|
+
expect(result.error_code).to eq(:auth_required)
|
|
88
|
+
# The message field is untouched — hosts still fingerprinting it work.
|
|
89
|
+
expect(result.error_message).to eq("service request failed (401): User not authenticated")
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
it "classifies a failure the adapter reported without raising" do
|
|
94
|
+
Dir.mktmpdir do |dir|
|
|
95
|
+
adapter = double
|
|
96
|
+
allow(adapter).to receive(:send_message)
|
|
97
|
+
.and_return(success: false, session: {}, error_message: "No LID for 919999000001")
|
|
98
|
+
allow(adapter).to receive(:fetch_qr_code).and_return("qr")
|
|
99
|
+
allow(adapter).to receive(:connection_status).and_return({})
|
|
100
|
+
config = build_config(path: File.join(dir, "session.json"), adapter: adapter)
|
|
101
|
+
provider = described_class.new(configuration: config)
|
|
102
|
+
|
|
103
|
+
expect(provider.deliver(to: "+1", body: "h").error_code).to eq(:recipient_unresolved)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
it "keeps a code the adapter supplied instead of re-guessing from the text" do
|
|
108
|
+
Dir.mktmpdir do |dir|
|
|
109
|
+
adapter = double
|
|
110
|
+
allow(adapter).to receive(:send_message)
|
|
111
|
+
.and_return(success: false, session: {}, error_code: :not_on_whatsapp, error_message: "No LID for 919999000001")
|
|
112
|
+
allow(adapter).to receive(:fetch_qr_code).and_return("qr")
|
|
113
|
+
allow(adapter).to receive(:connection_status).and_return({})
|
|
114
|
+
config = build_config(path: File.join(dir, "session.json"), adapter: adapter)
|
|
115
|
+
provider = described_class.new(configuration: config)
|
|
116
|
+
|
|
117
|
+
expect(provider.deliver(to: "+1", body: "h").error_code).to eq(:not_on_whatsapp)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
it "leaves a successful send without an error code" do
|
|
122
|
+
Dir.mktmpdir do |dir|
|
|
123
|
+
adapter = double
|
|
124
|
+
allow(adapter).to receive(:send_message).and_return(success: true, message_id: "w1", session: {})
|
|
125
|
+
allow(adapter).to receive(:fetch_qr_code).and_return("qr")
|
|
126
|
+
allow(adapter).to receive(:connection_status).and_return({})
|
|
127
|
+
config = build_config(path: File.join(dir, "session.json"), adapter: adapter)
|
|
128
|
+
provider = described_class.new(configuration: config)
|
|
129
|
+
|
|
130
|
+
expect(provider.deliver(to: "+1", body: "h").error_code).to be_nil
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
72
134
|
it "raises for missing adapter methods in scan" do
|
|
73
135
|
Dir.mktmpdir do |dir|
|
|
74
136
|
adapter = Object.new
|