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.
@@ -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 sender is resolved to a phone @c.us: if the resolved phone is
86
- // one of our outbound targets, remember the @lid alias too. rememberTarget at
87
- // send time only ever stores @c.us ids, but for privacy-number accounts the
88
- // *chat* is keyed by the @lid so a reconnect backfill that replays targets
89
- // via chat-id lookup could never re-open that chat, permanently losing any
90
- // disconnect-window replies. Duplicate captures across the @c.us/@lid pair are
91
- // fine: the contract is at-least-once and the host dedupes on messageId.
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 to: string = msg.to || '';
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 unlike inbound there is no contact handle to resolve it through
306
- // (msg.getContact() resolves the SENDER here, the operator). Rare in
307
- // practice: operator-initiated chats are keyed by the phone @c.us. Drop
308
- // with a log rather than forward an unmatchable body.
309
- if (to.endsWith('@lid')) {
310
- console.log(`Dropping fromMe message to unresolved @lid chat for ${userId}`);
311
- return;
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
+ }
@@ -1,4 +1,4 @@
1
1
  module WhatsAppNotifier
2
- VERSION = "0.8.1"
2
+ VERSION = "0.9.1"
3
3
 
4
4
  end
@@ -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)
@@ -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