diff --git a/Dockerfile b/Dockerfile index 443f860ed..f2e56e049 100644 --- a/Dockerfile +++ b/Dockerfile @@ -112,10 +112,13 @@ COPY package*.json ./ # scripts/postinstall.js rides along: `npm ci` below runs the hook, which fails # when the file is missing. With the patcher present the hook applies it in # --best-effort mode; the explicit fatal run right after is the real gate. -COPY scripts/postinstall.js scripts/patch-wwebjs-201832.js scripts/wwebjs-201832.patch ./scripts/ +COPY scripts/postinstall.js scripts/patch-wwebjs-201832.js scripts/wwebjs-201832.patch scripts/patch-wwebjs-newsletter-preview.js ./scripts/ -# Install production dependencies only, then apply the backport patcher (needs `patch`). -RUN npm ci --omit=dev && node scripts/patch-wwebjs-201832.js && npm cache clean --force +# Install production dependencies only, then apply the backports. +RUN npm ci --omit=dev \ + && node scripts/patch-wwebjs-201832.js \ + && node scripts/patch-wwebjs-newsletter-preview.js \ + && npm cache clean --force # Replace the npm the base image bundles. npm is not on the request path — the entrypoint runs # `node dist/main` — but it stays in the image because the operator runbooks drive it diff --git a/package.json b/package.json index d20b5018b..e6a45bde1 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "lint": "eslint \"{src,apps,libs,test}/**/*.ts\"", "lint:fix": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", "test": "jest", - "test:scripts": "node --test scripts/postinstall.spec.js", + "test:scripts": "node --test scripts/postinstall.spec.js scripts/patch-wwebjs-newsletter-preview.spec.js", "test:watch": "jest --watch", "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", diff --git a/scripts/check-dockerignore.mjs b/scripts/check-dockerignore.mjs index e79377cfd..677671883 100644 --- a/scripts/check-dockerignore.mjs +++ b/scripts/check-dockerignore.mjs @@ -143,6 +143,7 @@ const mustKeep = [ 'dashboard/src/main.tsx', 'scripts/postinstall.js', 'scripts/patch-wwebjs-201832.js', + 'scripts/patch-wwebjs-newsletter-preview.js', 'scripts/wwebjs-201832.patch', 'docker-entrypoint.sh', 'docs/01-project-overview.md', diff --git a/scripts/patch-wwebjs-newsletter-preview.js b/scripts/patch-wwebjs-newsletter-preview.js new file mode 100644 index 000000000..19f470f70 --- /dev/null +++ b/scripts/patch-wwebjs-newsletter-preview.js @@ -0,0 +1,73 @@ +/** + * Add destination-chat context to whatsapp-web.js link-preview generation. + * + * WhatsApp Web selects a different preview transport for newsletters. The released + * whatsapp-web.js 1.34.7 call omits the chat, so the internal action cannot select + * that transport even though the message is later sent through the newsletter job. + * + * The source transform is deliberately exact and self-disabling. An unknown shape + * fails the production image build instead of silently shipping without the fix. + */ +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const DEFAULT_WWJS = path.join(__dirname, '..', 'node_modules', 'whatsapp-web.js'); +const UTILS_PATH = path.join('src', 'util', 'Injected', 'Utils.js'); +const LEGACY_CALL = + "let preview = await window\n .require('WAWebLinkPreviewChatAction')\n .getLinkPreview(link);"; +const CONTEXT_CALL = + "let preview = await window\n .require('WAWebLinkPreviewChatAction')\n .getLinkPreview(link, chat);"; + +function occurrences(source, needle) { + return source.split(needle).length - 1; +} + +function applyBackport(wwjsDir = DEFAULT_WWJS) { + const utilsFile = path.join(wwjsDir, UTILS_PATH); + if (!fs.existsSync(utilsFile)) { + throw new Error(`whatsapp-web.js Utils.js not found at ${utilsFile}`); + } + + const source = fs.readFileSync(utilsFile, 'utf8'); + const legacyCount = occurrences(source, LEGACY_CALL); + const contextCount = occurrences(source, CONTEXT_CALL); + + if (legacyCount === 0 && contextCount === 1) { + return { + skipped: true, + reason: 'installed whatsapp-web.js already passes destination chat context', + }; + } + if (legacyCount !== 1 || contextCount !== 0) { + throw new Error( + `unsupported Utils.js shape (legacy calls: ${legacyCount}, context calls: ${contextCount}); ` + + 're-evaluate the newsletter preview backport against the installed whatsapp-web.js', + ); + } + + fs.writeFileSync(utilsFile, source.replace(LEGACY_CALL, CONTEXT_CALL)); + return { skipped: false, note: 'destination chat context added to link previews' }; +} + +function run() { + const bestEffort = process.argv.includes('--best-effort'); + try { + const result = applyBackport(); + console.log( + `patch-wwebjs-newsletter-preview: ${result.skipped ? `skipped — ${result.reason}` : result.note}`, + ); + } catch (error) { + if (bestEffort) { + console.warn(`patch-wwebjs-newsletter-preview: skipped — ${error.message}`); + return; + } + console.error(`patch-wwebjs-newsletter-preview: ${error.message}`); + process.exitCode = 1; + } +} + +if (require.main === module) run(); + +module.exports = { applyBackport, LEGACY_CALL, CONTEXT_CALL }; diff --git a/scripts/patch-wwebjs-newsletter-preview.spec.js b/scripts/patch-wwebjs-newsletter-preview.spec.js new file mode 100644 index 000000000..a748f69cd --- /dev/null +++ b/scripts/patch-wwebjs-newsletter-preview.spec.js @@ -0,0 +1,58 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { applyBackport } = require('./patch-wwebjs-newsletter-preview.js'); + +const LEGACY_CALL = + "let preview = await window\n .require('WAWebLinkPreviewChatAction')\n .getLinkPreview(link);"; +const CONTEXT_CALL = + "let preview = await window\n .require('WAWebLinkPreviewChatAction')\n .getLinkPreview(link, chat);"; + +function makeDependency(source) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'openwa-newsletter-preview-')); + const utils = path.join(root, 'src', 'util', 'Injected', 'Utils.js'); + fs.mkdirSync(path.dirname(utils), { recursive: true }); + fs.writeFileSync(utils, source); + return { root, utils }; +} + +test('patches link-preview generation to include the destination chat', () => { + const { root, utils } = makeDependency(`before\n${LEGACY_CALL}\nafter\n`); + + const result = applyBackport(root); + + assert.deepEqual(result, { skipped: false, note: 'destination chat context added to link previews' }); + assert.equal(fs.readFileSync(utils, 'utf8'), `before\n${CONTEXT_CALL}\nafter\n`); +}); + +test('is idempotent when destination chat context is already present', () => { + const { root, utils } = makeDependency(`before\n${CONTEXT_CALL}\nafter\n`); + const original = fs.readFileSync(utils, 'utf8'); + + assert.deepEqual(applyBackport(root), { + skipped: true, + reason: 'installed whatsapp-web.js already passes destination chat context', + }); + assert.equal(fs.readFileSync(utils, 'utf8'), original); +}); + +test('rejects an unknown dependency shape without changing it', () => { + const { root, utils } = makeDependency('window.WWebJS.sendMessage = async () => {};\n'); + const original = fs.readFileSync(utils, 'utf8'); + + assert.throws(() => applyBackport(root), /unsupported Utils\.js shape/); + assert.equal(fs.readFileSync(utils, 'utf8'), original); +}); + +test('rejects an ambiguous dependency shape without changing it', () => { + const { root, utils } = makeDependency(`${LEGACY_CALL}\n${CONTEXT_CALL}\n`); + const original = fs.readFileSync(utils, 'utf8'); + + assert.throws(() => applyBackport(root), /unsupported Utils\.js shape/); + assert.equal(fs.readFileSync(utils, 'utf8'), original); +}); diff --git a/scripts/postinstall.js b/scripts/postinstall.js index b44696dda..97c88d51f 100644 --- a/scripts/postinstall.js +++ b/scripts/postinstall.js @@ -1,7 +1,7 @@ /** * Post-install hook (npm `postinstall`). * - * Two conditional steps, each skipped when its target is absent so the hook is a no-op where the + * Three conditional steps, each skipped when its target is absent so the hook is a no-op where the * piece is missing (the Docker builder stage copies package*.json long before any source): * * 1. `npm run dashboard:ci` when dashboard/ exists — the dashboard carries its own lockfile and @@ -13,6 +13,8 @@ * itself decides fatality: under --best-effort it warns and exits 0 for a pristine-but- * unpatched tree (no `patch` binary, Baileys-only user), but exits 1 for a HALF-patched * tree — which must never be waved through. So a non-zero status here is propagated as-is. + * 3. `node scripts/patch-wwebjs-newsletter-preview.js --best-effort` when present. The production + * Docker stage runs it again without best-effort, making dependency drift a build failure. * * Structured like scripts/patch-wwebjs-201832.js: pure planning + injectable spawn, so the spec * (scripts/postinstall.spec.js, node:test) exercises every branch without a real npm run. @@ -44,6 +46,15 @@ function planSteps(root) { options: { stdio: 'inherit', cwd: root }, }); } + const previewPatcher = path.join(root, 'scripts', 'patch-wwebjs-newsletter-preview.js'); + if (fs.existsSync(previewPatcher)) { + steps.push({ + name: 'whatsapp-web.js newsletter preview backport (scripts/patch-wwebjs-newsletter-preview.js --best-effort)', + command: process.execPath, + args: [previewPatcher, '--best-effort'], + options: { stdio: 'inherit', cwd: root }, + }); + } return steps; } diff --git a/scripts/postinstall.spec.js b/scripts/postinstall.spec.js index 3dd01fb76..cfafa22af 100644 --- a/scripts/postinstall.spec.js +++ b/scripts/postinstall.spec.js @@ -18,13 +18,18 @@ const { planSteps, failureReason, run } = require('./postinstall.js'); const OK = { status: 0, signal: null, error: null }; /** Bare temp dir optionally holding a dashboard/ and/or the patch script. */ -function makeRoot({ dashboard = false, patcher = false } = {}) { +function makeRoot({ dashboard = false, patcher = false, previewPatcher = false } = {}) { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'openwa-postinstall-')); if (dashboard) fs.mkdirSync(path.join(root, 'dashboard')); - if (patcher) { + if (patcher || previewPatcher) { fs.mkdirSync(path.join(root, 'scripts')); + } + if (patcher) { fs.writeFileSync(path.join(root, 'scripts', 'patch-wwebjs-201832.js'), '// stub\n'); } + if (previewPatcher) { + fs.writeFileSync(path.join(root, 'scripts', 'patch-wwebjs-newsletter-preview.js'), '// stub\n'); + } return root; } @@ -59,6 +64,14 @@ test('planSteps: patcher only plans the best-effort backport via the current nod assert.deepEqual(steps[0].args.slice(1), ['--best-effort']); }); +test('planSteps: newsletter preview patcher plans its own best-effort backport', () => { + const steps = planSteps(makeRoot({ previewPatcher: true })); + assert.equal(steps.length, 1); + assert.equal(steps[0].command, process.execPath); + assert.match(steps[0].args[0], /patch-wwebjs-newsletter-preview\.js$/); + assert.deepEqual(steps[0].args.slice(1), ['--best-effort']); +}); + test('planSteps: both present plans dashboard first, patcher second', () => { const steps = planSteps(makeRoot({ dashboard: true, patcher: true })); assert.equal(steps.length, 2); @@ -66,6 +79,14 @@ test('planSteps: both present plans dashboard first, patcher second', () => { assert.equal(steps[1].command, process.execPath); }); +test('planSteps: dashboard and both patchers run in stable order', () => { + const steps = planSteps(makeRoot({ dashboard: true, patcher: true, previewPatcher: true })); + assert.equal(steps.length, 3); + assert.equal(steps[0].command, 'npm run dashboard:ci'); + assert.match(steps[1].args[0], /patch-wwebjs-201832\.js$/); + assert.match(steps[2].args[0], /patch-wwebjs-newsletter-preview\.js$/); +}); + test('run: nothing to do exits 0 and never spawns', () => { const { calls, spawn } = fakeSpawn([OK]); assert.equal(run(makeRoot(), spawn), 0); diff --git a/src/engine/adapters/wwebjs-newsletter-preview.spec.ts b/src/engine/adapters/wwebjs-newsletter-preview.spec.ts new file mode 100644 index 000000000..5158d6f0c --- /dev/null +++ b/src/engine/adapters/wwebjs-newsletter-preview.spec.ts @@ -0,0 +1,11 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +describe('whatsapp-web.js newsletter link-preview backport', () => { + it('passes the destination chat to link-preview generation', () => { + const dependencyRoot = path.dirname(require.resolve('whatsapp-web.js/package.json')); + const source = fs.readFileSync(path.join(dependencyRoot, 'src', 'util', 'Injected', 'Utils.js'), 'utf8'); + + expect(source).toContain('.getLinkPreview(link, chat);'); + }); +});