Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions scripts/check-dockerignore.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
73 changes: 73 additions & 0 deletions scripts/patch-wwebjs-newsletter-preview.js
Original file line number Diff line number Diff line change
@@ -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 };
58 changes: 58 additions & 0 deletions scripts/patch-wwebjs-newsletter-preview.spec.js
Original file line number Diff line number Diff line change
@@ -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);
});
13 changes: 12 additions & 1 deletion scripts/postinstall.js
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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;
}

Expand Down
25 changes: 23 additions & 2 deletions scripts/postinstall.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -59,13 +64,29 @@ 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);
assert.equal(steps[0].command, 'npm run dashboard:ci');
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);
Expand Down
11 changes: 11 additions & 0 deletions src/engine/adapters/wwebjs-newsletter-preview.spec.ts
Original file line number Diff line number Diff line change
@@ -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);');
});
});
Loading