File size: 12,731 Bytes
46252cd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
/**
 * Build-time backport of upstream whatsapp-web.js#201832 into the installed
 * whatsapp-web.js. Run after `npm ci` in the Docker production stage.
 *
 * Background: WhatsApp Web build 2.3000.x (rolled out ~2026-07-14) renamed the
 * serialized message-id property `id._serialized` to `id.$1` (a minifier-mangled
 * name). whatsapp-web.js 1.34.7 (what OpenWA pins) reads `_serialized` in the
 * Message constructor and ~40 downstream sites, so message ids, acks, quoted-
 * message resolution, and media downloads all break. Upstream fix #201832 adds a
 * `Base._normalizeId()` helper and reapplies it across the model constructors.
 * This script backports that fix into node_modules at image build time.
 *
 * Self-removing: it no-ops once the installed whatsapp-web.js already defines
 * `Base._normalizeId` (i.e. upstream shipped #201832 and OpenWA bumped its dep).
 *
 * Why `patch` and not `git apply`: a bare `git -C node_modules/whatsapp-web.js
 * apply` silently no-ops ("Skipped / 0 files changed") because the parent repo's
 * .git interferes with the diff's blob-SHA index lines. `patch` has no repo
 * discovery and applies cleanly.
 *
 * Known reject on 1.34.7: Contact.js hunk #2 targets a LID-aware block()/
 * unblock() path that does not exist in 1.34.7 (the PR's base is ahead there).
 * It is harmless β€” the rename cannot break absent code β€” so it is intentionally
 * dropped. Any OTHER reject means the installed shape drifted from the backport's
 * expected base; we abort the build loudly rather than ship a silently partial
 * patch.
 */
'use strict';

const fs = require('fs');
const path = require('path');
const { execFileSync } = require('child_process');

const DEFAULT_WWJS = path.join(__dirname, '..', 'node_modules', 'whatsapp-web.js');
const DEFAULT_PATCH = path.join(__dirname, 'wwebjs-201832.patch');
// The single hunk expected to reject on 1.34.7 (targets absent LID-block code).
const EXPECTED_REJECTS = new Set(['src/structures/Contact.js.rej']);

// Every normalization site the backport must land, so a hunk that fails to apply can never pass
// unnoticed. `patch` always writes a .rej for a hunk it couldn't place, but a hunk placed at the
// WRONG offset would be silent β€” these assertions are what close that gap.
//
// This list must cover every file the patch normalizes, not just the structure constructors, because
// the skip branch below uses it to decide a half-patched tree apart from an upstream fix. `patch`
// applies the diff in file order and Utils.js is applied LAST, one file after Message.js β€” which is
// what the skip branch keys on. Omitting a file here means a run that died in that window leaves a
// tree where every assertion passes, so every later run stands down and the missing site latches in
// permanently. One entry per file: `siteLanded` resolves a file's marker with `.find`, so a second
// entry for the same path would never be read.
const REQUIRED_SITES = [
  ['src/structures/Base.js', /static _normalizeId\(id\)/],
  ['src/structures/Message.js', /this\.id = Base\._normalizeId\(data\.id\)/],
  ['src/structures/Chat.js', /this\.id = Base\._normalizeId\(data\.id\)/],
  ['src/structures/Contact.js', /this\.id = Base\._normalizeId\(data\.id\)/],
  ['src/structures/Channel.js', /this\.id = Base\._normalizeId\(data\.id\)/],
  ['src/structures/Broadcast.js', /this\.id = Base\._normalizeId\(data\.id\)/],
  ['src/structures/GroupNotification.js', /this\.id = Base\._normalizeId\(data\.id\)/],
  ['src/structures/ClientInfo.js', /this\.wid = Base\._normalizeId\(data\.wid\)/],
  // The browser-side normalizer every inbound message crosses on its way to Node β€” the site the
  // structure constructors above cannot cover, and the last file the patch writes.
  ['src/util/Injected/Utils.js', /_serialized: msg\.id\.\$1/],
  ['src/Client.js', /res\.gid\._serialized \|\| res\.gid\.\$1/],
  ['src/structures/GroupChat.js', /pWid\._serialized \|\| pWid\.\$1/],
];

/** Artifacts `patch` can leave behind. `.~N~` are GNU's backup-if-mismatch copies of pre-patch source. */
const ARTIFACT_RE = /(\.rej|\.orig|~)$/;

/** True once `rel`'s REQUIRED_SITES marker is present in the installed tree. */
function siteLanded(wwjsDir, rel) {
  const marker = REQUIRED_SITES.find(([r]) => r === rel)[1];
  return marker.test(fs.readFileSync(path.join(wwjsDir, rel), 'utf8'));
}

/**
 * Flag an error raised against a tree that is no longer pristine β€” either this run wrote to it, or a
 * previous run left it half-patched. `--best-effort` degrades on a pre-flight failure (no `patch`
 * binary β€” nothing ran, tree untouched) but must never swallow one of these: `patch` applies hunks as
 * it goes, so failing mid-apply leaves whatsapp-web.js half-patched β€” which the self-disable check
 * above reads as healthy, latching the broken tree in on every later run.
 */
function partialTree(err) {
  err.leftPartialTree = true;
  return err;
}

function findArtifacts(root, dir = root) {
  const out = [];
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
    if (entry.name === 'node_modules' || entry.name === '.git') continue;
    const full = path.join(dir, entry.name);
    if (entry.isDirectory()) {
      out.push(...findArtifacts(root, full));
    } else if (ARTIFACT_RE.test(entry.name)) {
      out.push(path.relative(root, full));
    }
  }
  return out;
}

function applyBackport(wwjsDir = DEFAULT_WWJS, patchFile = DEFAULT_PATCH) {
  const baseJs = path.join(wwjsDir, 'src', 'structures', 'Base.js');
  if (!fs.existsSync(baseJs)) {
    throw new Error(`whatsapp-web.js not found at ${wwjsDir}`);
  }
  // Self-removal: once the installed whatsapp-web.js normalizes ids itself, this backport steps
  // aside. Detect on the id-normalizing READ in the Message constructor rather than on Base.js's
  // helper: Message.js is the load-bearing site (OpenWA reads `msg.id._serialized` in ~40 places),
  // and keying off Base.js alone would treat a half-patched tree β€” helper present, constructor not β€”
  // as "already fixed" and ship it. Matched loosely (`Base._normalizeId(` OR an inline `$1` fallback)
  // so a future upstream release that fixes this differently still stands the patcher down.
  const msgJs = path.join(wwjsDir, 'src', 'structures', 'Message.js');
  const msgSrc = fs.readFileSync(msgJs, 'utf8');
  if (/_normalizeId\(|\.\$1/.test(msgSrc)) {
    // Message.js normalizing is necessary but NOT sufficient to stand down. `patch` writes as it goes and
    // Message.js is patched early, so a run that died part-way through lands here too β€” and skipping would
    // latch that half-patched tree in permanently, because every later run takes this same branch and never
    // reaches the assertions below. An upstream release that fixes this lands every site at once; a crashed
    // run does not. Proving the tree is whole is what tells the two apart.
    const missing = REQUIRED_SITES.filter(([rel]) => !siteLanded(wwjsDir, rel));
    if (missing.length) {
      // `partialTree`, so `--best-effort` cannot warn past this. The tree this detects is precisely the
      // one that flag must never wave through: half-patched, self-disable reading it as healthy, and
      // unrepairable by any later run.
      throw partialTree(
        new Error(
          'whatsapp-web.js is PARTIALLY patched β€” Message.js normalizes ids but ' +
            `${missing.map(([rel]) => rel).join(', ')} did not land. A previous run left the tree half-patched; ` +
            'it cannot repair itself. Reinstall the dependency (`rm -rf node_modules/whatsapp-web.js && npm ci`) ' +
            'and re-run. If the installed version genuinely ships its own fix, drop this backport instead.',
        ),
      );
    }
    return { skipped: true, reason: 'installed whatsapp-web.js already normalizes message ids' };
  }

  // Apply the real upstream diff via `patch` (no git-discovery interference).
  // Flags, each load-bearing:
  //   --no-backup-if-mismatch  GNU patch defaults to writing `<file>.~1~` backups for every file
  //                            whose hunks needed an offset or rejected β€” 328K of pre-patch source
  //                            that would otherwise ship in the image. (`-V none` does NOT do this;
  //                            it only picks the backup *style*.) BSD patch has no such default,
  //                            which is why this is invisible on macOS and only bites in Debian CI.
  //   -F0                      No fuzz: a hunk must match exactly, never slide onto a similar-looking
  //                            neighbouring block. Fuzzed application would be silent β€” invisible to
  //                            both the reject-set check and the assertions below.
  //   --ignore-whitespace      Absorbs a trivial context-indent mismatch in GroupChat.js.
  //   -f -N                    Never prompt; `-f` also forces already-applied hunks to reject loudly
  //                            rather than be skipped silently.
  // With these, GNU and BSD patch produce byte-identical trees, so a local macOS run faithfully
  // reproduces the Debian image build.
  try {
    execFileSync(
      'patch',
      ['-p1', '-d', wwjsDir, '--no-backup-if-mismatch', '-N', '-f', '-F0', '--ignore-whitespace', '-i', patchFile],
      { stdio: 'pipe' },
    );
  } catch (e) {
    // `patch` exits 1 when hunks reject β€” expected here (Contact.js hunk #2), and the reject set is
    // verified below. Anything else (2 = serious trouble, ENOENT = `patch` not installed) is a real
    // failure: rethrow it rather than let the assertions below misreport it as version skew.
    if (e.status !== 1) {
      const detail = e.stderr ? String(e.stderr).trim() : e.message;
      const err = new Error(`\`patch\` failed (${e.code ?? `exit ${e.status}`}): ${detail}`);
      // ENOENT means `patch` never executed, so the tree is untouched and degrading is still safe.
      throw e.code === 'ENOENT' ? err : partialTree(err);
    }
  }

  const artifacts = findArtifacts(wwjsDir);
  const unexpected = artifacts.filter(a => !EXPECTED_REJECTS.has(a));
  if (unexpected.length) {
    throw partialTree(
      new Error(
        `unexpected patch artifact(s) β€” version skew vs the backport base: ${unexpected.join(', ')}. ` +
          'Re-evaluate scripts/wwebjs-201832.patch against the installed whatsapp-web.js.',
      ),
    );
  }

  // Verify EVERY normalization site landed β€” a hunk placed at a wrong offset leaves no .rej.
  for (const [rel, marker] of REQUIRED_SITES) {
    if (!siteLanded(wwjsDir, rel)) {
      throw partialTree(
        new Error(`${rel} was not patched (missing ${marker}) β€” aborting rather than ship a partial backport.`),
      );
    }
  }

  // Clean up the expected .rej (Contact.js hunk #2 intentionally dropped).
  for (const a of artifacts) fs.unlinkSync(path.join(wwjsDir, a));

  return {
    skipped: false,
    note: 'applied (Contact.js LID-block hunk intentionally skipped β€” absent in 1.34.7)',
  };
}

if (require.main === module) {
  // `--best-effort` (used by the postinstall hook) warns instead of failing. The image build runs
  // without it: there, an unpatched whatsapp-web.js must abort the build rather than ship broken.
  // Locally the same failure is only a degraded dev install β€” `patch` may not exist at all (Windows
  // without WSL), and a Baileys-only user has no reason to care β€” so breaking `npm install` over it
  // would be worse than the bug.
  const bestEffort = process.argv.includes('--best-effort');
  const target = process.argv.find(a => !a.startsWith('--') && a !== process.argv[0] && a !== process.argv[1]);
  try {
    const res = applyBackport(target || DEFAULT_WWJS);
    console.log(`patch-wwebjs-201832: ${res.skipped ? `skipped β€” ${res.reason}` : res.note}`);
  } catch (e) {
    // `--best-effort` degrades only while the tree is still pristine β€” an unpatched whatsapp-web.js is a
    // known-degraded dev install, which is the trade this flag exists to make. A HALF-patched one is not
    // that trade: it is undetectable afterwards (the self-disable check reads it as healthy) and would
    // ship silently. Fail on it regardless of the flag.
    if (bestEffort && !e.leftPartialTree) {
      console.warn(
        `patch-wwebjs-201832: skipped β€” ${e.message}\n` +
          '  Inbound media/message ids may be broken on current WhatsApp Web builds (#747).\n' +
          '  The published Docker image applies this automatically; see scripts/patch-wwebjs-201832.js.',
      );
      return;
    }
    console.error(`patch-wwebjs-201832: ${e.message}`);
    process.exit(1);
  }
}

module.exports = { applyBackport, DEFAULT_WWJS, DEFAULT_PATCH, EXPECTED_REJECTS };