whatsapp_notifier 0.9.3 → 0.9.4

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 819dcc600d0048b4dfc4c8af3536b6fc72c370f369eab26b9535da151693527c
4
- data.tar.gz: f471a271b9fe8b855c4125993bf0e83b305f27f4e7ca748d685e27c6f5741081
3
+ metadata.gz: 338f3e790a991a451717a73f0867344775fe2b3275f422686ef1cb0c7937b964
4
+ data.tar.gz: 5027b7672782506107e9edf3e1e27b0ea2294bcafc73f8097fb127e23754b9cd
5
5
  SHA512:
6
- metadata.gz: b7333fa14a494d7bdb1365888087a8c42befe27401b01845bc2cd257a7f2fbf010cdfae1a7fe944cfc104d71add24ec8a92fccaef6c7771e6cc5dce80dbbba93
7
- data.tar.gz: 483341fc7e694da4c720eb805dace1279db52bb90c1fa195e227b1ff849b566aa8bca9ed9a616a28ea7165de73c0955a1d1343da12085cb8d10663bd9a5af7a2
6
+ metadata.gz: 733fa2d201bbcfaaf4d56df17269fcab69cda02709fb4fe728d2ebc8d918960419bcabbe6441ee091f25b6fce1ed42092a4860e3552278e24096fd79e528c920
7
+ data.tar.gz: 28118d7946fb4efba76b0b4ba3cc9ca9269a3df26736a9470296e1524d8ab3e8bed6ccb749a3bed8ff33790a48b9ea133cc57f1fae73b79ad126689b3ab281eb
@@ -16,6 +16,7 @@ module WhatsAppNotifier
16
16
  inbound.ts
17
17
  init_gate.ts
18
18
  media.ts
19
+ message_id.ts
19
20
  metrics.ts
20
21
  send.ts
21
22
  sessions.ts
@@ -33,7 +33,8 @@ import {
33
33
  mediaGetResponse,
34
34
  mediaDeleteResponse
35
35
  } from './media';
36
- import { sentMessageId, sendValidationError, fetchMedia, captionOptions } from './send';
36
+ import { sentMessageId, sendValidationError, fetchMedia, captionOptions, isLidResolutionError } from './send';
37
+ import { MESSAGE_MODEL_ID_PATCH, ensureSerializedId } from './message_id';
37
38
 
38
39
  const app = new Hono();
39
40
  const port = Number(process.env.PORT || 3001);
@@ -136,6 +137,12 @@ async function pushSessionEvent(userId: string, event: 'session_ready') {
136
137
  // webhook). The catch keeps a single bad message from killing the listener.
137
138
  async function captureInbound(userId: string, msg: any) {
138
139
  try {
140
+ // Second line of defence for the missing `id._serialized` (see
141
+ // message_id.ts): the page-side patch is the real fix, this rebuilds
142
+ // the id from whatever crossed the boundary. It has to happen BEFORE
143
+ // media resolution — downloadMedia reads `this.id._serialized` off
144
+ // this exact object.
145
+ ensureSerializedId(msg);
139
146
  await processInbound(userId, msg, {
140
147
  resolveMedia: resolveMediaForMessage,
141
148
  push: pushWebhook
@@ -224,7 +231,51 @@ function clearChromiumSingletonLocks(userId: string) {
224
231
 
225
232
  function isTransientSendError(error: unknown) {
226
233
  const message = error instanceof Error ? error.message : String(error);
227
- return message.includes("getChat") || message.includes("Cannot read properties of undefined");
234
+ return message.includes("getChat") || message.includes("Cannot read properties of undefined") ||
235
+ // Retryable only because the retry loop resolves the LID between
236
+ // attempts (see warmLidMapping) — on its own this would spin five
237
+ // times on the same missing chat-table row.
238
+ isLidResolutionError(error);
239
+ }
240
+
241
+ // Install the in-page getMessageModel wrapper that restores `id._serialized`
242
+ // (see message_id.ts for why WhatsApp stopped handing it over).
243
+ //
244
+ // Best effort by design: a session whose page is mid-navigation just keeps the
245
+ // Node-side rebuild in captureInbound. Never rejects — the ready handler must
246
+ // carry on to the backfill either way.
247
+ async function installMessageIdPatch(userId: string, client: Client) {
248
+ try {
249
+ const page = (client as any).pupPage;
250
+ if (!page) return 'no-page';
251
+ const result = await page.evaluate(MESSAGE_MODEL_ID_PATCH);
252
+ console.log(`Message-id patch for User ${userId}: ${result}`);
253
+ return result;
254
+ } catch (e) {
255
+ console.error(`Message-id patch failed for ${userId}`, e);
256
+ return 'failed';
257
+ }
258
+ }
259
+
260
+ // Make WhatsApp resolve the recipient's LID, after a send has already failed
261
+ // for the want of it.
262
+ //
263
+ // getContactLidAndPhone runs WhatsApp's own queryWidExists for the number,
264
+ // which is what writes the LID row the send path then reads. Without it a
265
+ // first-contact chat (or one WhatsApp has since re-keyed) fails inside the
266
+ // WhatsApp bundle with "Lid is missing in chat table" / "No LID for user" —
267
+ // around a quarter of production sends at the time of writing.
268
+ //
269
+ // Best effort: the send is retried regardless, so a resolver hiccup can never
270
+ // turn a deliverable message into a hard failure.
271
+ async function warmLidMapping(client: Client, chatId: string) {
272
+ try {
273
+ const resolver = (client as any).getContactLidAndPhone;
274
+ if (typeof resolver !== 'function') return;
275
+ await resolver.call(client, [chatId]);
276
+ } catch (e) {
277
+ console.error(`LID warm-up failed for ${chatId}`, e);
278
+ }
228
279
  }
229
280
 
230
281
  async function waitForClientReady(clientData: ClientData, timeoutMs = 30000): Promise<void> {
@@ -245,6 +296,10 @@ async function sendMessageWithRetry(client: Client, clientData: ClientData, chat
245
296
 
246
297
  // Wait for the internal WWeb store to be fully loaded before first attempt
247
298
  await waitForClientReady(clientData);
299
+ // The LID warm-up is deliberately NOT run up front: most recipients
300
+ // already have their chat-table row and would only pay an extra WhatsApp
301
+ // query for it — a real cost on a 500-recipient broadcast. The retry loop
302
+ // warms only the recipients that actually fail (see warmLidMapping).
248
303
 
249
304
  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
250
305
  try {
@@ -263,6 +318,10 @@ async function sendMessageWithRetry(client: Client, clientData: ClientData, chat
263
318
  throw error;
264
319
  }
265
320
 
321
+ // A LID failure is not a hydration problem — the mapping is simply
322
+ // absent, so re-run the resolver before spending the next attempt.
323
+ if (isLidResolutionError(error)) await warmLidMapping(client, chatId);
324
+
266
325
  // Wait longer between retries to give the store time to hydrate
267
326
  await new Promise((resolve) => setTimeout(resolve, attempt * 3000));
268
327
  }
@@ -344,11 +403,23 @@ async function getOrCreateClient(userId: string): Promise<ClientData> {
344
403
  clearReadyTimer(clientData);
345
404
  initRetries.reset(userId);
346
405
  console.log(`Client is READY for User ${userId}`);
406
+ // Re-arm the message-id repair on EVERY ready: whatsapp-web.js
407
+ // re-injects its own window.WWebJS on each page load, which drops our
408
+ // wrapper, and 'ready' is the one event that fires again after a
409
+ // reload. Must run before the backfill below, so replayed messages
410
+ // carry real ids too.
411
+ const patched = installMessageIdPatch(userId, client);
347
412
  // Wake the host NOW — a campaign parked on "session down" should not
348
413
  // wait out a polling interval when the session just came back.
349
414
  pushSessionEvent(userId, 'session_ready').catch(console.error);
350
- // Replay anything that arrived while we were disconnected.
351
- backfillInbound(userId, client).catch((e) => console.error(`Backfill failed for ${userId}`, e));
415
+ // Replay anything that arrived while we were disconnected — after the
416
+ // patch, so replayed messages carry real ids and their media can be
417
+ // fetched. A failed patch must not cancel the backfill: the Node-side
418
+ // rebuild in captureInbound still applies.
419
+ patched
420
+ .catch(() => undefined)
421
+ .then(() => backfillInbound(userId, client))
422
+ .catch((e) => console.error(`Backfill failed for ${userId}`, e));
352
423
  });
353
424
 
354
425
  // Capture BOTH directions of every 1:1 chat. Only 'message_create' — it
@@ -621,6 +692,9 @@ app.post('/send/:userId', async (c) => {
621
692
  data.lastUsed = Date.now();
622
693
  // messageId is the echo-dedupe key: this send fires its own fromMe
623
694
  // message_create, which the host must match by id (see send.ts).
695
+ // Repaired first for the same reason as capture — an id-less send
696
+ // response means the echo comes back as a duplicate operator bubble.
697
+ ensureSerializedId(sent);
624
698
  const messageId = sentMessageId(sent);
625
699
  // Register the id so the echo is suppressed service-side (no media
626
700
  // re-download, no queue slot, no webhook — see inbound.ts). Best
@@ -0,0 +1,157 @@
1
+ import { test, expect } from 'bun:test';
2
+ import { buildSerializedId, ensureSerializedId, MESSAGE_MODEL_ID_PATCH } from './message_id';
3
+
4
+ // ── buildSerializedId ──
5
+ //
6
+ // The format is WhatsApp's own, and whatsapp-web.js parses it back apart in
7
+ // Client#getMessageById: `fromMe_remote_id`, plus `_participant` when there
8
+ // is one.
9
+ test('buildSerializedId rebuilds the three-part id', () => {
10
+ expect(buildSerializedId({ fromMe: false, remote: '919999000001@c.us', id: 'ABC123' }))
11
+ .toBe('false_919999000001@c.us_ABC123');
12
+ expect(buildSerializedId({ fromMe: true, remote: '919999000001@c.us', id: 'ABC123' }))
13
+ .toBe('true_919999000001@c.us_ABC123');
14
+ });
15
+
16
+ test('buildSerializedId appends the participant as a fourth part', () => {
17
+ expect(buildSerializedId({
18
+ fromMe: false, remote: '12036@g.us', id: 'ABC123', participant: '919999000001@c.us'
19
+ })).toBe('false_12036@g.us_ABC123_919999000001@c.us');
20
+ });
21
+
22
+ // getMessageModel flattens `remote` to a string, but a Wid object still
23
+ // reaches us in other shapes (and participant is never flattened) — accept
24
+ // both rather than silently producing "[object Object]" in an id.
25
+ test('buildSerializedId accepts Wid objects for remote and participant', () => {
26
+ expect(buildSerializedId({
27
+ fromMe: false,
28
+ remote: { _serialized: '919999000001@c.us' },
29
+ id: 'ABC123',
30
+ participant: { _serialized: '919999000002@c.us' }
31
+ })).toBe('false_919999000001@c.us_ABC123_919999000002@c.us');
32
+ });
33
+
34
+ // A partial id is worse than none: it looks real, gets accepted as a lookup
35
+ // key, and resolves to nothing.
36
+ test('buildSerializedId returns null when a required part is missing', () => {
37
+ expect(buildSerializedId(null)).toBeNull();
38
+ expect(buildSerializedId(undefined)).toBeNull();
39
+ expect(buildSerializedId({})).toBeNull();
40
+ expect(buildSerializedId({ fromMe: false, id: 'ABC123' })).toBeNull(); // no remote
41
+ expect(buildSerializedId({ fromMe: false, remote: '919999000001@c.us' })).toBeNull(); // no id
42
+ expect(buildSerializedId({ fromMe: false, remote: {}, id: 'ABC' })).toBeNull(); // unusable Wid
43
+ expect(buildSerializedId({ fromMe: false, remote: '919999000001@c.us', id: '' })).toBeNull();
44
+ });
45
+
46
+ // ── ensureSerializedId ──
47
+ //
48
+ // It MUTATES the message: whatsapp-web.js reads `this.id._serialized` off the
49
+ // very object we hold (Message#downloadMedia passes it into the page), so
50
+ // repairing a copy would fix our own bookkeeping and leave media broken.
51
+ test('ensureSerializedId writes the rebuilt id back onto the message', () => {
52
+ const msg: any = { id: { fromMe: false, remote: '919999000001@c.us', id: 'ABC123' } };
53
+
54
+ expect(ensureSerializedId(msg)).toBe('false_919999000001@c.us_ABC123');
55
+ expect(msg.id._serialized).toBe('false_919999000001@c.us_ABC123');
56
+ });
57
+
58
+ // The day WhatsApp hands the property back, this must not overwrite it with a
59
+ // reconstruction.
60
+ test('ensureSerializedId leaves an existing id untouched', () => {
61
+ const msg: any = { id: { fromMe: false, remote: '919999000001@c.us', id: 'ABC', _serialized: 'REAL' } };
62
+
63
+ expect(ensureSerializedId(msg)).toBe('REAL');
64
+ expect(msg.id._serialized).toBe('REAL');
65
+ });
66
+
67
+ // Capture must survive a malformed message — never throw, never invent.
68
+ test('ensureSerializedId reports null for an unrepairable message', () => {
69
+ expect(ensureSerializedId(null)).toBeNull();
70
+ expect(ensureSerializedId(undefined)).toBeNull();
71
+ expect(ensureSerializedId({})).toBeNull();
72
+
73
+ const msg: any = { id: { fromMe: false } };
74
+ expect(ensureSerializedId(msg)).toBeNull();
75
+ expect(msg.id._serialized).toBeUndefined();
76
+ });
77
+
78
+ // ── the page-side patch ──
79
+ //
80
+ // Exercised the way the page runs it: eval the source against a fake window
81
+ // carrying a stand-in WWebJS. This is the primary fix (it runs where the
82
+ // accessor still works), so its branches are worth covering directly.
83
+ function runPatch(win: any): string {
84
+ const globalAny = globalThis as any;
85
+ const previous = globalAny.window;
86
+ globalAny.window = win;
87
+ try {
88
+ return (0, eval)(MESSAGE_MODEL_ID_PATCH);
89
+ } finally {
90
+ if (previous === undefined) delete globalAny.window;
91
+ else globalAny.window = previous;
92
+ }
93
+ }
94
+
95
+ // A live MsgKey still answers `_serialized` in-page — take it straight from
96
+ // there rather than reconstructing.
97
+ test('the patch fills _serialized from the live key', () => {
98
+ const win: any = { WWebJS: { getMessageModel: (m: any) => ({ id: { ...m.id, _serialized: undefined } }) } };
99
+
100
+ expect(runPatch(win)).toBe('patched');
101
+
102
+ const model = win.WWebJS.getMessageModel({
103
+ id: { fromMe: false, remote: '919999000001@c.us', id: 'ABC', _serialized: 'false_919999000001@c.us_ABC' }
104
+ });
105
+ expect(model.id._serialized).toBe('false_919999000001@c.us_ABC');
106
+ });
107
+
108
+ // toString() on a MsgKey yields the serialized id; on a plain object it yields
109
+ // "[object Object]", which would pass a truthiness check and poison every id
110
+ // written from it — so that shape must fall through to the manual rebuild.
111
+ test('the patch ignores a "[object Object]" toString and rebuilds from parts', () => {
112
+ const win: any = { WWebJS: { getMessageModel: (m: any) => ({ id: { ...m.id } }) } };
113
+ runPatch(win);
114
+
115
+ const model = win.WWebJS.getMessageModel({
116
+ id: { fromMe: true, remote: { _serialized: '919999000001@c.us' }, id: 'XYZ' }
117
+ });
118
+ expect(model.id._serialized).toBe('true_919999000001@c.us_XYZ');
119
+ });
120
+
121
+ test('the patch uses a MsgKey toString when there is no accessor', () => {
122
+ const win: any = { WWebJS: { getMessageModel: (m: any) => ({ id: { fromMe: m.id.fromMe } }) } };
123
+ runPatch(win);
124
+
125
+ const key = { fromMe: false, toString: () => 'false_919999000001@c.us_TSTR' };
126
+ expect(win.WWebJS.getMessageModel({ id: key }).id._serialized).toBe('false_919999000001@c.us_TSTR');
127
+ });
128
+
129
+ // A model without its id still carries the message; the repair must never be
130
+ // what breaks capture.
131
+ test('the patch leaves an unrepairable model alone and never throws', () => {
132
+ const win: any = { WWebJS: { getMessageModel: () => ({ id: { fromMe: false } }) } };
133
+ runPatch(win);
134
+
135
+ expect(win.WWebJS.getMessageModel({ id: { fromMe: false } }).id._serialized).toBeUndefined();
136
+ expect(win.WWebJS.getMessageModel({}).id._serialized).toBeUndefined();
137
+ expect(win.WWebJS.getMessageModel({ id: null }).id._serialized).toBeUndefined();
138
+ });
139
+
140
+ test('the patch preserves an already-serialized model id', () => {
141
+ const win: any = { WWebJS: { getMessageModel: () => ({ id: { _serialized: 'REAL' } }) } };
142
+ runPatch(win);
143
+
144
+ expect(win.WWebJS.getMessageModel({ id: {} }).id._serialized).toBe('REAL');
145
+ });
146
+
147
+ // Re-armed on every 'ready', so double application has to be free — wrapping
148
+ // the wrapper would re-run the repair for no gain.
149
+ test('the patch is idempotent and reports when it cannot install', () => {
150
+ const win: any = { WWebJS: { getMessageModel: (m: any) => ({ id: { ...m.id } }) } };
151
+
152
+ expect(runPatch(win)).toBe('patched');
153
+ expect(runPatch(win)).toBe('already-patched');
154
+
155
+ expect(runPatch({})).toBe('no-store');
156
+ expect(runPatch({ WWebJS: {} })).toBe('no-store');
157
+ });
@@ -0,0 +1,146 @@
1
+ // Repairs the serialized WhatsApp message id, which current WhatsApp Web no
2
+ // longer hands across the puppeteer boundary.
3
+ //
4
+ // Every message model whatsapp-web.js emits is produced in-page by
5
+ // window.WWebJS.getMessageModel(msg) and then JSON-serialized on its way out
6
+ // (the Msg 'add' hook calls window.onAddMessageEvent, a page binding; bindings
7
+ // and page.evaluate both marshal by JSON). JSON only copies OWN ENUMERABLE
8
+ // properties, so a value that WhatsApp exposes as a prototype accessor is
9
+ // silently dropped. MsgKey._serialized is now exactly that: readable in-page,
10
+ // absent by the time the model reaches Node.
11
+ //
12
+ // The damage is not cosmetic — `id._serialized` is the message's only handle:
13
+ // * Message#downloadMedia passes it into the page (Msg.get(msgId) →
14
+ // Msg.getMessagesById([msgId])); with `undefined` the lookup throws, so
15
+ // EVERY media message resolves "download_failed" and the host renders a
16
+ // permanent "Media unavailable" bubble.
17
+ // * normalizeInbound falls back to `${counterparty}-${timestamp}`, which is
18
+ // not the id the host stored for its own /send, so the fromMe echo of our
19
+ // own send can no longer be deduped — and two messages in the same second
20
+ // on one chat collide onto a single fallback id.
21
+ //
22
+ // Two layers, because a page-side patch can be lost (a reload re-injects
23
+ // whatsapp-web.js's own WWebJS) and a Node-side rebuild depends on parts that
24
+ // may themselves stop crossing the boundary:
25
+ // 1. MESSAGE_MODEL_ID_PATCH — wraps getMessageModel IN THE PAGE, where the
26
+ // accessor still works. Every emitted model gains a real, own
27
+ // `_serialized` before it is marshalled. This is the primary fix.
28
+ // 2. ensureSerializedId — Node-side rebuild from the surviving key parts,
29
+ // applied to each captured message. Covers the window before the patch
30
+ // lands and any session where the patch could not be installed.
31
+
32
+ // The key as it survives the boundary. Everything is optional on purpose:
33
+ // this type describes damaged input, not the ideal shape.
34
+ export type MessageKey = {
35
+ fromMe?: boolean;
36
+ remote?: unknown;
37
+ id?: string;
38
+ participant?: unknown;
39
+ _serialized?: string;
40
+ };
41
+
42
+ // WhatsApp's own serialization format, confirmed by whatsapp-web.js's parser
43
+ // in Client#getMessageById: 3 parts, or 4 when the message carries a
44
+ // participant (`fromMe_remote_id[_participant]`).
45
+ //
46
+ // Returns null unless every REQUIRED part is present — a partial id would be
47
+ // worse than none: it would look real, be accepted as a lookup key, and
48
+ // resolve to nothing.
49
+ export function buildSerializedId(key: MessageKey | null | undefined): string | null {
50
+ if (!key) return null;
51
+ const remote = widString(key.remote);
52
+ const id = typeof key.id === 'string' ? key.id : '';
53
+ if (!remote || !id) return null;
54
+
55
+ const participant = widString(key.participant);
56
+ const head = `${key.fromMe ? 'true' : 'false'}_${remote}_${id}`;
57
+ return participant ? `${head}_${participant}` : head;
58
+ }
59
+
60
+ // `remote` and `participant` are Wid objects in-page. getMessageModel already
61
+ // flattens `remote` to its string form, but participant gets no such
62
+ // treatment, and a Wid that DID survive as an object still carries
63
+ // `_serialized` — so accept either shape.
64
+ function widString(wid: unknown): string {
65
+ if (typeof wid === 'string') return wid;
66
+ if (wid && typeof wid === 'object') {
67
+ const serialized = (wid as { _serialized?: unknown })._serialized;
68
+ if (typeof serialized === 'string') return serialized;
69
+ }
70
+ return '';
71
+ }
72
+
73
+ // Give one captured message a usable `id._serialized`, mutating it in place —
74
+ // whatsapp-web.js reads `this.id._serialized` off the very object we hold, so
75
+ // repairing a copy would fix our own bookkeeping and leave downloadMedia
76
+ // broken.
77
+ //
78
+ // Returns the serialized id (existing or rebuilt), or null when the key is too
79
+ // damaged to rebuild. Never throws: a capture path must not die on a malformed
80
+ // message.
81
+ export function ensureSerializedId(msg: { id?: MessageKey } | null | undefined): string | null {
82
+ const key = msg && msg.id;
83
+ if (!key) return null;
84
+ if (typeof key._serialized === 'string' && key._serialized) return key._serialized;
85
+
86
+ const rebuilt = buildSerializedId(key);
87
+ if (rebuilt) key._serialized = rebuilt;
88
+ return rebuilt;
89
+ }
90
+
91
+ // The page-side patch, as source to hand to page.evaluate.
92
+ //
93
+ // Idempotent (a flag on window), and it never replaces a working
94
+ // `_serialized` — the day WhatsApp puts the property back, this becomes a
95
+ // no-op wrapper instead of a competing implementation.
96
+ //
97
+ // In-page the accessor is readable, so the id is taken straight from the live
98
+ // MsgKey; the manual rebuild is only the last resort, and mirrors
99
+ // buildSerializedId above.
100
+ //
101
+ // Returns a short status string so the caller can log which happened.
102
+ export const MESSAGE_MODEL_ID_PATCH = `(() => {
103
+ if (typeof window === 'undefined' || !window.WWebJS) return 'no-store';
104
+ if (window.__waMessageIdPatch) return 'already-patched';
105
+ const original = window.WWebJS.getMessageModel;
106
+ if (typeof original !== 'function') return 'no-store';
107
+
108
+ const widString = (wid) => {
109
+ if (typeof wid === 'string') return wid;
110
+ if (wid && typeof wid === 'object' && typeof wid._serialized === 'string') return wid._serialized;
111
+ return '';
112
+ };
113
+
114
+ window.WWebJS.getMessageModel = function (message) {
115
+ const model = original.apply(this, arguments);
116
+ try {
117
+ if (model && model.id && !model.id._serialized) {
118
+ const key = message && message.id;
119
+ let serialized = key && typeof key._serialized === 'string' ? key._serialized : '';
120
+ if (!serialized && key && typeof key.toString === 'function') {
121
+ const asString = key.toString();
122
+ // toString() on a plain object yields "[object Object]" —
123
+ // a value that would pass a truthiness check and poison
124
+ // every id it is written to.
125
+ if (asString && asString.indexOf('[object') !== 0) serialized = asString;
126
+ }
127
+ if (!serialized && key) {
128
+ const remote = widString(key.remote);
129
+ const participant = widString(key.participant);
130
+ if (remote && typeof key.id === 'string' && key.id) {
131
+ serialized = (key.fromMe ? 'true' : 'false') + '_' + remote + '_' + key.id;
132
+ if (participant) serialized += '_' + participant;
133
+ }
134
+ }
135
+ if (serialized) model.id = Object.assign({}, model.id, { _serialized: serialized });
136
+ }
137
+ } catch (e) {
138
+ // A model without its id still carries the message; never let the
139
+ // repair itself break capture.
140
+ }
141
+ return model;
142
+ };
143
+
144
+ window.__waMessageIdPatch = true;
145
+ return 'patched';
146
+ })()`;
@@ -1,5 +1,5 @@
1
1
  import { test, expect } from 'bun:test';
2
- import { sentMessageId, sendValidationError, fetchMedia, captionOptions } from './send';
2
+ import { sentMessageId, sendValidationError, fetchMedia, captionOptions, isLidResolutionError } from './send';
3
3
 
4
4
  // The id the host stores against its outbound record — it MUST be the real
5
5
  // serialized WhatsApp id so the fromMe echo of this send dedupes on it.
@@ -76,3 +76,23 @@ test('captionOptions omits the caption entirely for caption-less files', () => {
76
76
  expect(captionOptions(undefined)).toEqual({});
77
77
  expect(captionOptions(null)).toEqual({});
78
78
  });
79
+
80
+ // ── LID resolution failures ──
81
+ //
82
+ // These are WhatsApp's OWN assertion texts, thrown inside its bundle when the
83
+ // chat table has no LID row for the recipient. There is no error code to key
84
+ // on — the text is the whole signal — and the stack tail WhatsApp appends
85
+ // (its minified bundle URL) must not stop the match.
86
+ test('isLidResolutionError recognises both WhatsApp LID assertions', () => {
87
+ expect(isLidResolutionError(new Error('Lid is missing in chat table\ns (https://static.whatsapp.net/rsrc.php/v4/y3/r/QOqeh94VsFD.js:84:180)')))
88
+ .toBe(true);
89
+ expect(isLidResolutionError(new Error('No LID for user\ns (https://static.whatsapp.net/rsrc.php/v4/y3/r/QOqeh94VsFD.js:84:180)')))
90
+ .toBe(true);
91
+ expect(isLidResolutionError('No LID for user')).toBe(true); // thrown as a bare string
92
+ });
93
+
94
+ test('isLidResolutionError leaves unrelated send failures alone', () => {
95
+ expect(isLidResolutionError(new Error('Evaluation failed: TypeError'))).toBe(false);
96
+ expect(isLidResolutionError(new Error('User not authenticated'))).toBe(false);
97
+ expect(isLidResolutionError(undefined)).toBe(false);
98
+ });
@@ -50,6 +50,21 @@ export async function fetchMedia(messageMedia: MediaFactory, mediaUrl: string) {
50
50
  return messageMedia.fromUrl(mediaUrl, { unsafeMime: true });
51
51
  }
52
52
 
53
+ // WhatsApp Web addresses chats by LID now, and resolves the LID for a plain
54
+ // phone jid out of its own chat table. When that row is missing the send dies
55
+ // inside WhatsApp's own bundle with one of these — WhatsApp assertion texts,
56
+ // not whatsapp-web.js errors, so there is nothing to match on but the text:
57
+ // "Lid is missing in chat table" — 1:1 send, chat row has no LID
58
+ // "No LID for user" — recipient never resolved at all
59
+ //
60
+ // The row is populated by asking WhatsApp whether the number exists
61
+ // (queryWidExists, reached through Client#getContactLidAndPhone), which is why
62
+ // the warm-then-retry in index.ts clears both.
63
+ export function isLidResolutionError(error: unknown): boolean {
64
+ const message = error instanceof Error ? error.message : String(error);
65
+ return message.includes('Lid is missing in chat table') || message.includes('No LID for user');
66
+ }
67
+
53
68
  // sendMessage options for a media send: caption only when there IS one. A
54
69
  // caption-less file arrives with message "" — omitting the key entirely
55
70
  // matches a hand-sent file instead of attaching an empty caption.
@@ -1,4 +1,4 @@
1
1
  module WhatsAppNotifier
2
- VERSION = "0.9.3"
2
+ VERSION = "0.9.4"
3
3
 
4
4
  end
@@ -58,7 +58,8 @@ RSpec.describe "WhatsAppNotifier::Generators::InstallServiceGenerator" do
58
58
  generator.copy_service_files
59
59
 
60
60
  sources = generator.copied.map(&:first)
61
- expect(sources).to match_array(%w[index.ts history.ts inbound.ts init_gate.ts media.ts metrics.ts send.ts sessions.ts package.json bun.lock])
61
+ expect(sources).to match_array(%w[index.ts history.ts inbound.ts init_gate.ts media.ts message_id.ts
62
+ metrics.ts send.ts sessions.ts package.json bun.lock])
62
63
  expect(sources.grep(/test|node_modules|\.wwebjs|\.puppeteer/)).to be_empty
63
64
  expect(generator.copied.map(&:last)).to all(start_with("whatsapp_service/"))
64
65
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: whatsapp_notifier
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.3
4
+ version: 0.9.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kshitiz Sinha
@@ -91,6 +91,8 @@ files:
91
91
  - lib/whatsapp_notifier/services/web_automation/init_gate.ts
92
92
  - lib/whatsapp_notifier/services/web_automation/media.test.ts
93
93
  - lib/whatsapp_notifier/services/web_automation/media.ts
94
+ - lib/whatsapp_notifier/services/web_automation/message_id.test.ts
95
+ - lib/whatsapp_notifier/services/web_automation/message_id.ts
94
96
  - lib/whatsapp_notifier/services/web_automation/metrics.test.ts
95
97
  - lib/whatsapp_notifier/services/web_automation/metrics.ts
96
98
  - lib/whatsapp_notifier/services/web_automation/package.json