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 +4 -4
- data/lib/generators/whatsapp_notifier/install_service_generator.rb +1 -0
- data/lib/whatsapp_notifier/services/web_automation/index.ts +78 -4
- data/lib/whatsapp_notifier/services/web_automation/message_id.test.ts +157 -0
- data/lib/whatsapp_notifier/services/web_automation/message_id.ts +146 -0
- data/lib/whatsapp_notifier/services/web_automation/send.test.ts +21 -1
- data/lib/whatsapp_notifier/services/web_automation/send.ts +15 -0
- data/lib/whatsapp_notifier/version.rb +1 -1
- data/spec/generators/install_service_generator_spec.rb +2 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 338f3e790a991a451717a73f0867344775fe2b3275f422686ef1cb0c7937b964
|
|
4
|
+
data.tar.gz: 5027b7672782506107e9edf3e1e27b0ea2294bcafc73f8097fb127e23754b9cd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 733fa2d201bbcfaaf4d56df17269fcab69cda02709fb4fe728d2ebc8d918960419bcabbe6441ee091f25b6fce1ed42092a4860e3552278e24096fd79e528c920
|
|
7
|
+
data.tar.gz: 28118d7946fb4efba76b0b4ba3cc9ca9269a3df26736a9470296e1524d8ab3e8bed6ccb749a3bed8ff33790a48b9ea133cc57f1fae73b79ad126689b3ab281eb
|
|
@@ -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
|
-
|
|
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.
|
|
@@ -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
|
|
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.
|
|
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
|