9b395f5cc3
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
Build Chrome Extension / build (push) Has been cancelled
Trigger Website Rebuild (Docs Updated) / dispatch (push) Has been cancelled
358 lines
15 KiB
JavaScript
358 lines
15 KiB
JavaScript
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
import { createHash } from 'node:crypto';
|
|
|
|
const LINKEDIN_DOMAIN = 'www.linkedin.com';
|
|
|
|
function normalizeWhitespace(value) {
|
|
return String(value ?? '').replace(/[\u00a0\u202f]/g, ' ').replace(/\s+/g, ' ').trim();
|
|
}
|
|
|
|
function unwrapEvaluateResult(payload) {
|
|
if (payload && typeof payload === 'object' && 'data' in payload && 'session' in payload) return payload.data;
|
|
return payload;
|
|
}
|
|
|
|
function normalizeName(value) {
|
|
return normalizeWhitespace(value)
|
|
.replace(/\s*[•·]\s*(?:1st|2nd|3rd\+?|degree connection).*$/i, '')
|
|
.replace(/\s+LinkedIn.*$/i, '')
|
|
.toLowerCase();
|
|
}
|
|
|
|
function isLinkedInHost(hostname) {
|
|
const host = String(hostname || '').toLowerCase();
|
|
return host === 'linkedin.com' || host.endsWith('.linkedin.com');
|
|
}
|
|
|
|
function canonicalizeLinkedInThreadUrl(value) {
|
|
const raw = normalizeWhitespace(value);
|
|
if (!raw) return '';
|
|
try {
|
|
const url = new URL(raw);
|
|
if (url.protocol !== 'https:' || url.username || url.password || url.port || !isLinkedInHost(url.hostname)) return '';
|
|
const match = url.pathname.match(/^\/messaging\/thread\/([^/]+)\/?$/i);
|
|
if (!match || !match[1]) return '';
|
|
url.hostname = 'www.linkedin.com';
|
|
url.hash = '';
|
|
url.search = '';
|
|
if (!url.pathname.endsWith('/')) url.pathname += '/';
|
|
return url.toString();
|
|
} catch {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
function hashText(value) {
|
|
return createHash('sha256').update(normalizeWhitespace(value)).digest('hex');
|
|
}
|
|
|
|
function textContainsNormalized(haystack, needle) {
|
|
const h = normalizeWhitespace(haystack).toLowerCase();
|
|
const n = normalizeWhitespace(needle).toLowerCase();
|
|
return !n || h.includes(n);
|
|
}
|
|
|
|
function selectBestHeaderName(headerNames, expectedName) {
|
|
const expected = normalizeName(expectedName);
|
|
const names = (Array.isArray(headerNames) ? headerNames : [])
|
|
.map(normalizeWhitespace)
|
|
.filter(Boolean);
|
|
return names.find((name) => normalizeName(name) === expected) || names[0] || '';
|
|
}
|
|
|
|
function assessThreadSafety(probe, expected) {
|
|
const expectedName = normalizeWhitespace(expected.expectedName);
|
|
const actualName = selectBestHeaderName(probe?.headerNames, expectedName);
|
|
const expectedThreadUrl = canonicalizeLinkedInThreadUrl(expected.threadUrl);
|
|
const actualThreadUrl = canonicalizeLinkedInThreadUrl(probe?.url || '');
|
|
const bodyText = String(probe?.bodyText || '');
|
|
|
|
if (probe?.authRequired) {
|
|
return { ok: false, blockReason: 'auth_required', expectedValue: expectedName, actualValue: actualName, observedUrl: actualThreadUrl };
|
|
}
|
|
|
|
if (probe?.searchFailure || /we didn't find anything|no results found|no results for/i.test(bodyText)) {
|
|
return { ok: false, blockReason: 'search_failure_visible', expectedValue: expectedName, actualValue: actualName, observedUrl: actualThreadUrl };
|
|
}
|
|
|
|
if (expectedThreadUrl && actualThreadUrl && expectedThreadUrl !== actualThreadUrl) {
|
|
return { ok: false, blockReason: 'thread_url_mismatch', expectedValue: expectedThreadUrl, actualValue: actualThreadUrl, observedUrl: actualThreadUrl };
|
|
}
|
|
|
|
if (!actualName || normalizeName(actualName) !== normalizeName(expectedName)) {
|
|
return { ok: false, blockReason: 'recipient_header_mismatch', expectedValue: expectedName, actualValue: actualName, observedUrl: actualThreadUrl };
|
|
}
|
|
|
|
if (!probe?.composerFound) {
|
|
return { ok: false, blockReason: 'composer_not_found', expectedValue: expectedName, actualValue: actualName, observedUrl: actualThreadUrl };
|
|
}
|
|
|
|
const expectedLastHash = normalizeWhitespace(expected.expectedLastHash);
|
|
if (expectedLastHash && expectedLastHash !== probe?.latestMessageHash) {
|
|
return { ok: false, blockReason: 'latest_message_mismatch', expectedValue: expectedLastHash, actualValue: probe?.latestMessageHash || '', observedUrl: actualThreadUrl };
|
|
}
|
|
|
|
const expectedLastText = normalizeWhitespace(expected.expectedLastText);
|
|
if (expectedLastText && !textContainsNormalized(bodyText, expectedLastText)) {
|
|
return { ok: false, blockReason: 'latest_message_mismatch', expectedValue: expectedLastText, actualValue: '', observedUrl: actualThreadUrl };
|
|
}
|
|
|
|
return { ok: true, blockReason: 'verified', expectedValue: expectedName, actualValue: actualName, observedUrl: actualThreadUrl };
|
|
}
|
|
|
|
function requireStringArg(args, key, label = key) {
|
|
const value = normalizeWhitespace(args[key]);
|
|
if (!value) throw new ArgumentError(`${label} is required`);
|
|
return value;
|
|
}
|
|
|
|
function requireLinkedInThreadUrl(value, label) {
|
|
const url = canonicalizeLinkedInThreadUrl(value);
|
|
if (!url) throw new ArgumentError(`${label} must be an exact https://www.linkedin.com/messaging/thread/<id>/ URL`);
|
|
return url;
|
|
}
|
|
|
|
function buildThreadProbeScript() {
|
|
return String.raw`(() => {
|
|
const marker = '__OPENCLI_LINKEDIN_PROBE__';
|
|
void marker;
|
|
const clean = (s) => String(s || '').replace(/[\u00a0\u202f]/g, ' ').replace(/\s+/g, ' ').trim();
|
|
const text = document.body ? (document.body.innerText || '') : '';
|
|
const lower = text.toLowerCase();
|
|
const authRequired = /\b(sign in|log in|join linkedin)\b/i.test(text)
|
|
|| /linkedin\.com\/(login|checkpoint|authwall)/i.test(location.href)
|
|
|| /captcha|verification required/i.test(text);
|
|
const searchFailure = /we didn't find anything|no results found|no results for/i.test(text);
|
|
|
|
const headerCandidates = [];
|
|
const selectors = [
|
|
'.msg-thread__link-to-profile',
|
|
'.msg-thread__link-to-profile span[aria-hidden="true"]',
|
|
'.msg-entity-lockup__entity-title',
|
|
'.msg-conversation-card__participant-names',
|
|
'main h1',
|
|
'main h2',
|
|
'[data-anonymize="person-name"]',
|
|
'a[href*="/in/"] span[aria-hidden="true"]',
|
|
'a[href*="/in/"]'
|
|
];
|
|
for (const selector of selectors) {
|
|
for (const el of Array.from(document.querySelectorAll(selector)).slice(0, 8)) {
|
|
const value = clean(el.innerText || el.textContent || el.getAttribute('aria-label'));
|
|
if (value && value.length <= 120 && !/^(message|messaging|send|profile|view profile)$/i.test(value)) {
|
|
headerCandidates.push(value);
|
|
}
|
|
}
|
|
}
|
|
|
|
const composer = Array.from(document.querySelectorAll('[contenteditable="true"][role="textbox"], div.msg-form__contenteditable[contenteditable="true"], [aria-label*="Write a message" i]'))
|
|
.find((el) => !el.closest('[aria-hidden="true"]') && el.offsetParent !== null);
|
|
|
|
const messageText = Array.from(document.querySelectorAll('.msg-s-message-list__event, .msg-s-event-listitem, [data-event-urn], .msg-s-message-group__meta, .msg-s-message-list-content'))
|
|
.map((el) => clean(el.innerText || el.textContent))
|
|
.filter(Boolean)
|
|
.join('\n');
|
|
const sourceText = messageText || text;
|
|
const sourceLines = sourceText.split(/\n+/).map(clean).filter(Boolean);
|
|
const lastMeaningfulLine = [...sourceLines].reverse().find((line) => !/^(send|reply|write a message|press enter to send)$/i.test(line)) || '';
|
|
|
|
return {
|
|
url: location.href,
|
|
title: document.title || '',
|
|
headerNames: Array.from(new Set(headerCandidates)).slice(0, 10),
|
|
bodyText: text,
|
|
composerFound: Boolean(composer),
|
|
composerText: composer ? clean(composer.innerText || composer.textContent) : '',
|
|
authRequired,
|
|
searchFailure,
|
|
latestMessageText: lastMeaningfulLine,
|
|
latestMessageHash: '',
|
|
};
|
|
})()`;
|
|
}
|
|
|
|
function buildFocusComposerScript() {
|
|
return String.raw`(() => {
|
|
const marker = '__OPENCLI_LINKEDIN_FOCUS_COMPOSER__';
|
|
void marker;
|
|
const clean = (s) => String(s || '').replace(/[\u00a0\u202f]/g, ' ').replace(/\s+/g, ' ').trim();
|
|
const composer = Array.from(document.querySelectorAll('[contenteditable="true"][role="textbox"], div.msg-form__contenteditable[contenteditable="true"], [aria-label*="Write a message" i]'))
|
|
.find((el) => !el.closest('[aria-hidden="true"]') && el.offsetParent !== null);
|
|
if (!composer) return { ok: false, error: 'composer_not_found', composerText: '' };
|
|
composer.focus();
|
|
composer.innerHTML = '';
|
|
composer.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'deleteContentBackward', data: null }));
|
|
return { ok: true, composerText: clean(composer.innerText || composer.textContent) };
|
|
})()`;
|
|
}
|
|
|
|
function buildReadComposerScript() {
|
|
return String.raw`(() => {
|
|
const marker = '__OPENCLI_LINKEDIN_READ_COMPOSER__';
|
|
void marker;
|
|
const clean = (s) => String(s || '').replace(/[\u00a0\u202f]/g, ' ').replace(/\s+/g, ' ').trim();
|
|
const composer = Array.from(document.querySelectorAll('[contenteditable="true"][role="textbox"], div.msg-form__contenteditable[contenteditable="true"], [aria-label*="Write a message" i]'))
|
|
.find((el) => !el.closest('[aria-hidden="true"]') && el.offsetParent !== null);
|
|
return { ok: Boolean(composer), composerText: composer ? clean(composer.innerText || composer.textContent) : '' };
|
|
})()`;
|
|
}
|
|
|
|
function buildClickSendScript() {
|
|
return String.raw`(() => {
|
|
const marker = '__OPENCLI_LINKEDIN_CLICK_SEND__';
|
|
void marker;
|
|
const buttons = Array.from(document.querySelectorAll('button'));
|
|
const send = buttons.find((button) => {
|
|
const text = (button.innerText || button.textContent || button.getAttribute('aria-label') || '').trim().toLowerCase();
|
|
return text === 'send' || text === 'send message';
|
|
});
|
|
if (!send) return { ok: false, error: 'send_button_not_found', sent: false };
|
|
if (send.disabled || send.getAttribute('aria-disabled') === 'true') return { ok: false, error: 'send_button_disabled', sent: false };
|
|
send.click();
|
|
return { ok: true, sent: true };
|
|
})()`;
|
|
}
|
|
|
|
async function probeThread(page) {
|
|
const result = unwrapEvaluateResult(await page.evaluate(buildThreadProbeScript()));
|
|
const latestText = normalizeWhitespace(result?.latestMessageText || '');
|
|
return {
|
|
...(result || {}),
|
|
latestMessageText: latestText,
|
|
latestMessageHash: latestText ? hashText(latestText) : '',
|
|
};
|
|
}
|
|
|
|
cli({
|
|
site: 'linkedin',
|
|
name: 'safe-send',
|
|
access: 'write',
|
|
description: 'Fail-closed LinkedIn message sender that verifies exact thread, recipient, and latest message before filling/sending',
|
|
domain: LINKEDIN_DOMAIN,
|
|
strategy: Strategy.UI,
|
|
browser: true,
|
|
args: [
|
|
{ name: 'thread-url', required: true, help: 'Exact LinkedIn messaging thread URL to open and verify' },
|
|
{ name: 'expected-name', required: true, help: 'Expected visible recipient name in the active thread header' },
|
|
{ name: 'message', required: true, help: 'Message body to send or dry-run' },
|
|
{ name: 'expected-last-text', help: 'Substring expected in the currently visible latest conversation context' },
|
|
{ name: 'expected-last-hash', help: 'SHA-256 hash of expected latest visible message text' },
|
|
{ name: 'send', type: 'bool', default: false, help: 'Actually click Send. Default is dry-run verification only.' },
|
|
{ name: 'screenshot', type: 'bool', default: false, help: 'Capture a screenshot during verification' },
|
|
],
|
|
columns: ['status', 'recipient', 'reason', 'thread_url', 'message_chars', 'screenshot'],
|
|
func: async (page, args) => {
|
|
if (!page) throw new CommandExecutionError('Browser session required for linkedin safe-send');
|
|
|
|
const threadUrl = requireLinkedInThreadUrl(requireStringArg(args, 'thread-url', '--thread-url'), '--thread-url');
|
|
const expectedName = requireStringArg(args, 'expected-name', '--expected-name');
|
|
const message = requireStringArg(args, 'message', '--message');
|
|
|
|
await page.goto('https://www.linkedin.com/messaging/');
|
|
await page.wait(4);
|
|
await page.goto(threadUrl);
|
|
// LinkedIn messaging often renders the shell first and hydrates the active
|
|
// thread header/messages a few seconds later. Wait long enough for the
|
|
// recipient header to appear so we fail closed on a real mismatch, not on
|
|
// a premature blank DOM snapshot.
|
|
await page.wait(12);
|
|
|
|
let beforeProbe = await probeThread(page);
|
|
const expectedLastText = normalizeWhitespace(args['expected-last-text']);
|
|
for (let attempt = 0; expectedLastText && attempt < 6 && !textContainsNormalized(beforeProbe.bodyText, expectedLastText); attempt += 1) {
|
|
await page.wait(2);
|
|
beforeProbe = await probeThread(page);
|
|
}
|
|
|
|
const safety = assessThreadSafety(beforeProbe, {
|
|
expectedName,
|
|
threadUrl,
|
|
expectedLastText: args['expected-last-text'],
|
|
expectedLastHash: args['expected-last-hash'],
|
|
});
|
|
|
|
if (safety.blockReason === 'auth_required') {
|
|
throw new AuthRequiredError(LINKEDIN_DOMAIN, 'LinkedIn safe-send requires an active signed-in LinkedIn browser session.');
|
|
}
|
|
|
|
if (!safety.ok) {
|
|
const observed = [
|
|
`Expected ${safety.expectedValue}; actual ${safety.actualValue || 'not_visible'} at ${safety.observedUrl || 'url_not_available'}`,
|
|
`Observed headers: ${(beforeProbe.headerNames || []).join(' | ') || 'no_visible_headers'}`,
|
|
`Title: ${beforeProbe.title || 'title_not_available'}`,
|
|
`Body: ${normalizeWhitespace(beforeProbe.bodyText || '').slice(0, 500)}`,
|
|
].join('\n');
|
|
throw new CommandExecutionError(
|
|
`LinkedIn safe-send blocked: ${safety.blockReason}`,
|
|
observed,
|
|
);
|
|
}
|
|
|
|
let screenshot = '';
|
|
if (args.screenshot && typeof page.screenshot === 'function') {
|
|
screenshot = await page.screenshot({ fullPage: false });
|
|
}
|
|
|
|
if (!args.send) {
|
|
return [{
|
|
status: 'verified_dry_run',
|
|
recipient: safety.actualValue,
|
|
reason: safety.blockReason,
|
|
thread_url: safety.observedUrl,
|
|
message_chars: message.length,
|
|
screenshot: screenshot ? 'captured' : '',
|
|
}];
|
|
}
|
|
|
|
const focus = unwrapEvaluateResult(await page.evaluate(buildFocusComposerScript()));
|
|
if (!focus?.ok) throw new CommandExecutionError(`LinkedIn safe-send blocked: ${focus?.error || 'composer_focus_failed'}`);
|
|
|
|
await page.insertText(message);
|
|
await page.wait(0.6 + Math.random() * 0.8);
|
|
|
|
const composer = unwrapEvaluateResult(await page.evaluate(buildReadComposerScript()));
|
|
if (!composer?.ok || normalizeWhitespace(composer.composerText) !== normalizeWhitespace(message)) {
|
|
throw new CommandExecutionError(
|
|
'LinkedIn safe-send blocked: composer_text_mismatch',
|
|
`Composer text did not exactly match intended message for ${expectedName}.`,
|
|
);
|
|
}
|
|
|
|
const afterFillProbe = await probeThread(page);
|
|
const afterFillSafety = assessThreadSafety(afterFillProbe, {
|
|
expectedName,
|
|
threadUrl,
|
|
expectedLastText: args['expected-last-text'],
|
|
expectedLastHash: args['expected-last-hash'],
|
|
});
|
|
if (!afterFillSafety.ok) {
|
|
throw new CommandExecutionError(`LinkedIn safe-send blocked after fill: ${afterFillSafety.blockReason}`);
|
|
}
|
|
|
|
const sent = unwrapEvaluateResult(await page.evaluate(buildClickSendScript()));
|
|
if (!sent?.ok || !sent.sent) {
|
|
throw new CommandExecutionError(`LinkedIn safe-send blocked: ${sent?.error || 'send_click_failed'}`);
|
|
}
|
|
|
|
await page.wait(0.8 + Math.random() * 1.2);
|
|
return [{
|
|
status: 'sent',
|
|
recipient: safety.actualValue,
|
|
reason: safety.blockReason,
|
|
thread_url: safety.observedUrl,
|
|
message_chars: message.length,
|
|
screenshot: screenshot ? 'captured' : '',
|
|
}];
|
|
},
|
|
});
|
|
|
|
export const __test__ = {
|
|
normalizeWhitespace,
|
|
unwrapEvaluateResult,
|
|
normalizeName,
|
|
canonicalizeLinkedInThreadUrl,
|
|
hashText,
|
|
assessThreadSafety,
|
|
};
|