diff --git a/cli-manifest.json b/cli-manifest.json index 1d2cc19ae..fd51c6855 100644 --- a/cli-manifest.json +++ b/cli-manifest.json @@ -14110,6 +14110,129 @@ "modulePath": "lichess/user.js", "sourceFile": "lichess/user.js" }, + { + "site": "linkedin", + "name": "inbox", + "description": "Read visible LinkedIn messaging inbox rows with thread URLs, previews, and unread hints", + "access": "read", + "domain": "www.linkedin.com", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "limit", + "type": "number", + "default": 40, + "required": false, + "help": "Maximum visible inbox rows to return" + }, + { + "name": "json", + "type": "bool", + "default": false, + "required": false, + "help": "Return compact JSON in inbox_json" + } + ], + "columns": [ + "index", + "name", + "unread", + "unread_count", + "unreadCount", + "timestamp", + "preview", + "thread_url", + "threadUrl", + "profile_url", + "profileUrl", + "rowText", + "inbox_json" + ], + "type": "js", + "modulePath": "linkedin/inbox.js", + "sourceFile": "linkedin/inbox.js", + "navigateBefore": true + }, + { + "site": "linkedin", + "name": "safe-send", + "description": "Fail-closed LinkedIn message sender that verifies exact thread, recipient, and latest message before filling/sending", + "access": "write", + "domain": "www.linkedin.com", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "thread-url", + "type": "str", + "required": true, + "help": "Exact LinkedIn messaging thread URL to open and verify" + }, + { + "name": "expected-name", + "type": "str", + "required": true, + "help": "Expected visible recipient name in the active thread header" + }, + { + "name": "message", + "type": "str", + "required": true, + "help": "Message body to send or dry-run" + }, + { + "name": "expected-last-text", + "type": "str", + "required": false, + "help": "Substring expected in the currently visible latest conversation context" + }, + { + "name": "expected-last-hash", + "type": "str", + "required": false, + "help": "SHA-256 hash of expected latest visible message text" + }, + { + "name": "send", + "type": "bool", + "default": false, + "required": false, + "help": "Actually click Send. Default is dry-run verification only." + }, + { + "name": "screenshot", + "type": "bool", + "default": false, + "required": false, + "help": "Capture a screenshot during verification" + } + ], + "columns": [ + "status", + "recipient", + "reason", + "thread_url", + "message_chars", + "expected", + "actual", + "url", + "screenshot", + "authRequired", + "bodyText", + "composerFound", + "composerText", + "headerNames", + "latestMessageHash", + "latestMessageText", + "searchFailure", + "title" + ], + "type": "js", + "modulePath": "linkedin/safe-send.js", + "sourceFile": "linkedin/safe-send.js", + "navigateBefore": true + }, { "site": "linkedin", "name": "search", @@ -14198,6 +14321,48 @@ "sourceFile": "linkedin/search.js", "navigateBefore": "https://www.linkedin.com" }, + { + "site": "linkedin", + "name": "thread-snapshot", + "description": "Load a LinkedIn messaging thread, scroll for available history, and return a full context snapshot", + "access": "read", + "domain": "www.linkedin.com", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "thread-url", + "type": "str", + "required": true, + "help": "Exact LinkedIn messaging thread URL to open and snapshot" + }, + { + "name": "max-scrolls", + "type": "number", + "default": 30, + "required": false, + "help": "Maximum upward scroll attempts to load older messages" + }, + { + "name": "json", + "type": "bool", + "default": false, + "required": false, + "help": "Return only JSON snapshot string in the snapshot_json field" + } + ], + "columns": [ + "thread_url", + "recipient", + "message_count", + "latest_text", + "snapshot_json" + ], + "type": "js", + "modulePath": "linkedin/thread-snapshot.js", + "sourceFile": "linkedin/thread-snapshot.js", + "navigateBefore": true + }, { "site": "linkedin", "name": "timeline", diff --git a/clis/linkedin/inbox.js b/clis/linkedin/inbox.js new file mode 100644 index 000000000..f5cb881fd --- /dev/null +++ b/clis/linkedin/inbox.js @@ -0,0 +1,222 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors'; + +const LINKEDIN_DOMAIN = 'www.linkedin.com'; + +function normalizeWhitespace(value) { + return String(value ?? '').replace(/[\u00a0\u202f]/g, ' ').replace(/\s+/g, ' ').trim(); +} + +function canonicalizeLinkedInUrl(value) { + const raw = normalizeWhitespace(value); + if (!raw) return ''; + try { + const url = new URL(raw, 'https://www.linkedin.com'); + if (!/linkedin\.com$/i.test(url.hostname) && !/\.linkedin\.com$/i.test(url.hostname)) return raw; + url.hash = ''; + url.search = ''; + if (!url.pathname.endsWith('/')) url.pathname += '/'; + return url.toString(); + } catch { + return raw; + } +} + +function requireLimitArg(value) { + const number = Number(value ?? 40); + if (!Number.isFinite(number) || number < 1 || number > 100) { + throw new ArgumentError('--limit must be a number between 1 and 100'); + } + return Math.floor(number); +} + +function buildInboxScript(limit) { + const maxRows = limit; + return String.raw`(async () => { + const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + const clean = (s) => String(s || '').replace(/[\u00a0\u202f]/g, ' ').replace(/\s+/g, ' ').trim(); + const bodyText = document.body ? (document.body.innerText || '') : ''; + const authRequired = /\b(sign in|log in|join linkedin)\b/i.test(bodyText) + || /linkedin\.com\/(login|checkpoint|authwall)/i.test(location.href) + || /captcha|verification required/i.test(bodyText); + + const pickHref = (root, pattern) => { + const links = Array.from(root.querySelectorAll('a[href]')); + const found = links.find((a) => pattern.test(a.href || a.getAttribute('href') || '')); + return found ? found.href : ''; + }; + + const isUnreadCard = (root, text) => { + const aria = clean(root.getAttribute('aria-label')); + const className = String(root.className || ''); + if (/unread/i.test(aria) || /unread/i.test(className)) return true; + if (root.querySelector('[class*="unread"], [aria-label*="unread" i]')) return true; + if (root.querySelector('.notification-badge, .msg-conversation-card__unread-count, [data-test-icon="unread-small"]')) return true; + const badges = Array.from(root.querySelectorAll('span, div')).map((el) => clean(el.innerText || el.textContent)).filter(Boolean); + return badges.some((v) => /^\d+$/.test(v) && text.includes(v)); + }; + + const parseCount = (root) => { + const candidates = Array.from(root.querySelectorAll('[class*="unread"], .notification-badge, span, div')) + .map((el) => clean(el.innerText || el.textContent)) + .filter(Boolean); + const hit = candidates.find((v) => /^\d+$/.test(v)); + return hit ? Number(hit) : 0; + }; + + const extractName = (root, lines) => { + const selectors = [ + '.msg-conversation-card__participant-names', + '.msg-conversation-card__participant-names span[aria-hidden="true"]', + '[data-anonymize="person-name"]', + 'h3', + 'a[href*="/in/"] span[aria-hidden="true"]', + 'a[href*="/in/"]' + ]; + for (const selector of selectors) { + const el = root.querySelector(selector); + const value = clean(el?.innerText || el?.textContent || el?.getAttribute?.('aria-label')); + if (value && value.length <= 120 && !/^(message|messaging|search messages)$/i.test(value)) return value; + } + return lines.find((line) => line.length <= 120 && !/^\d{1,2}:\d{2}|^(mon|tue|wed|thu|fri|sat|sun)$/i.test(line)) || ''; + }; + + const extractTime = (lines) => { + return lines.find((line) => /^(\d{1,2}:\d{2}\s*(am|pm)?|mon|tue|wed|thu|fri|sat|sun|yesterday|today|\d+d|\d+w)$/i.test(line)) || ''; + }; + + const cardSelectors = [ + '.msg-conversation-listitem', + '.msg-conversation-card', + 'li:has(a[href*="/messaging/thread/"])', + 'div:has(a[href*="/messaging/thread/"])' + ]; + let cards = []; + for (const selector of cardSelectors) { + try { + cards = Array.from(document.querySelectorAll(selector)); + if (cards.length) break; + } catch {} + } + + const seen = new Set(); + const rows = []; + for (const card of cards) { + const rowText = clean(card.innerText || card.textContent); + if (!rowText || rowText.length < 2) continue; + let threadUrl = pickHref(card, /\/messaging\/thread\//i); + if (!threadUrl && /active conversation/i.test(rowText) && /\/messaging\/thread\//i.test(location.href)) { + threadUrl = location.href; + } + const profileUrl = pickHref(card, /\/in\//i); + const key = threadUrl || rowText.slice(0, 180); + if (seen.has(key)) continue; + seen.add(key); + const lines = rowText.split(/\n+/).map(clean).filter(Boolean); + const name = extractName(card, lines); + const timestamp = extractTime(lines); + const preview = lines.filter((line) => line !== name && line !== timestamp && !/^\d+$/.test(line)).slice(-2).join(' '); + const unreadCount = parseCount(card); + rows.push({ + index: rows.length, + name, + threadUrl, + profileUrl, + timestamp, + preview, + unread: isUnreadCard(card, rowText) || unreadCount > 0, + unreadCount, + rowText, + }); + if (rows.length >= ${maxRows}) break; + } + + await sleep(250); + return { + url: location.href, + title: document.title || '', + authRequired, + extractedAt: new Date().toISOString(), + rowCount: rows.length, + rows, + }; + })()`; +} + +cli({ + site: 'linkedin', + name: 'inbox', + access: 'read', + description: 'Read visible LinkedIn messaging inbox rows with thread URLs, previews, and unread hints', + domain: LINKEDIN_DOMAIN, + strategy: Strategy.UI, + browser: true, + args: [ + { name: 'limit', type: 'number', default: 40, help: 'Maximum visible inbox rows to return' }, + { name: 'json', type: 'bool', default: false, help: 'Return compact JSON in inbox_json' }, + ], + columns: [ + 'index', + 'name', + 'unread', + 'unread_count', + 'unreadCount', + 'timestamp', + 'preview', + 'thread_url', + 'threadUrl', + 'profile_url', + 'profileUrl', + 'rowText', + 'inbox_json', + ], + func: async (page, args) => { + if (!page) throw new CommandExecutionError('Browser session required for linkedin inbox'); + const limit = requireLimitArg(args.limit); + await page.goto('https://www.linkedin.com/messaging/'); + await page.wait(8); + const snapshot = await page.evaluate(buildInboxScript(limit)); + if (snapshot?.authRequired) { + throw new AuthRequiredError(LINKEDIN_DOMAIN, 'LinkedIn inbox requires an active signed-in LinkedIn browser session.'); + } + const rows = Array.isArray(snapshot?.rows) ? snapshot.rows : []; + if (args.json) { + return [{ + index: 0, + name: '', + unread: rows.some((row) => row.unread), + unread_count: rows.reduce((sum, row) => sum + (Number(row.unreadCount) || (row.unread ? 1 : 0)), 0), + timestamp: '', + preview: '', + thread_url: '', + profile_url: '', + inbox_json: JSON.stringify({ + ...(snapshot || {}), + rows: rows.map((row) => ({ + ...row, + threadUrl: canonicalizeLinkedInUrl(row.threadUrl), + profileUrl: canonicalizeLinkedInUrl(row.profileUrl), + })), + }), + }]; + } + return rows.map((row, index) => ({ + index, + name: normalizeWhitespace(row.name), + unread: Boolean(row.unread), + unread_count: Number(row.unreadCount) || 0, + timestamp: normalizeWhitespace(row.timestamp), + preview: normalizeWhitespace(row.preview), + thread_url: canonicalizeLinkedInUrl(row.threadUrl), + profile_url: canonicalizeLinkedInUrl(row.profileUrl), + inbox_json: '', + })); + }, +}); + +export const __test__ = { + normalizeWhitespace, + canonicalizeLinkedInUrl, + requireLimitArg, + buildInboxScript, +}; diff --git a/clis/linkedin/inbox.test.js b/clis/linkedin/inbox.test.js new file mode 100644 index 000000000..5f44e5983 --- /dev/null +++ b/clis/linkedin/inbox.test.js @@ -0,0 +1,81 @@ +import { describe, expect, it, vi } from 'vitest'; +import { getRegistry } from '@jackwener/opencli/registry'; +import './inbox.js'; + +function makeFakePage(snapshot) { + return { + goto: vi.fn(async () => undefined), + wait: vi.fn(async () => undefined), + evaluate: vi.fn(async () => snapshot), + }; +} + +describe('linkedin inbox command', () => { + it('registers as a read command with unread and thread columns', () => { + const command = getRegistry().get('linkedin/inbox'); + expect(command).toBeDefined(); + expect(command.access).toBe('read'); + expect(command.columns).toEqual(expect.arrayContaining(['name', 'unread', 'thread_url', 'inbox_json'])); + }); + + it('opens LinkedIn messaging and returns visible inbox rows', async () => { + const command = getRegistry().get('linkedin/inbox'); + const page = makeFakePage({ + url: 'https://www.linkedin.com/messaging/', + title: 'Messaging | LinkedIn', + rows: [ + { + name: 'Charlett Braxton, BBM, PESC', + threadUrl: 'https://www.linkedin.com/messaging/thread/abc?x=1', + profileUrl: 'https://www.linkedin.com/in/charlett/', + timestamp: 'Thu', + preview: 'To internal but it depends on the know outside source have', + unread: true, + unreadCount: 1, + rowText: 'Charlett Braxton, BBM, PESC Thu To internal but it depends on the know outside source have 1', + }, + ], + }); + + const rows = await command.func(page, { limit: 10, json: false }); + + expect(page.goto).toHaveBeenCalledWith('https://www.linkedin.com/messaging/'); + expect(rows[0]).toMatchObject({ + name: 'Charlett Braxton, BBM, PESC', + unread: true, + unread_count: 1, + thread_url: 'https://www.linkedin.com/messaging/thread/abc/', + }); + }); + + it('uses current thread URL for the active conversation row when LinkedIn omits a row link', async () => { + const command = getRegistry().get('linkedin/inbox'); + const page = makeFakePage({ + url: 'https://www.linkedin.com/messaging/thread/current/', + rows: [ + { + name: 'Vishnu Singh, PESC', + threadUrl: 'https://www.linkedin.com/messaging/thread/current/', + timestamp: 'May 8', + preview: 'You: thanks for connecting Vishnu', + unread: false, + unreadCount: 0, + rowText: 'Vishnu Singh, PESC May 8 You: thanks for connecting Vishnu . Active conversation', + }, + ], + }); + + const rows = await command.func(page, { limit: 10, json: false }); + expect(rows[0].thread_url).toBe('https://www.linkedin.com/messaging/thread/current/'); + }); + + it('can return one compact JSON row for downstream reconciliation', async () => { + const command = getRegistry().get('linkedin/inbox'); + const page = makeFakePage({ rows: [{ name: 'Lempila Alphonsa', threadUrl: 'https://www.linkedin.com/messaging/thread/xyz/', unread: true, unreadCount: 1 }] }); + const rows = await command.func(page, { limit: 10, json: true }); + expect(rows).toHaveLength(1); + expect(rows[0].unread).toBe(true); + expect(rows[0].unread_count).toBe(1); + expect(rows[0].inbox_json).toContain('Lempila Alphonsa'); + }); +}); diff --git a/clis/linkedin/safe-send.js b/clis/linkedin/safe-send.js new file mode 100644 index 000000000..37bde6114 --- /dev/null +++ b/clis/linkedin/safe-send.js @@ -0,0 +1,337 @@ +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 normalizeName(value) { + return normalizeWhitespace(value) + .replace(/\s*[•·]\s*(?:1st|2nd|3rd\+?|degree connection).*$/i, '') + .replace(/\s+LinkedIn.*$/i, '') + .toLowerCase(); +} + +function canonicalizeLinkedInThreadUrl(value) { + const raw = normalizeWhitespace(value); + if (!raw) return ''; + try { + const url = new URL(raw); + if (!/linkedin\.com$/i.test(url.hostname) && !/\.linkedin\.com$/i.test(url.hostname)) return raw; + url.hash = ''; + url.search = ''; + if (!url.pathname.endsWith('/')) url.pathname += '/'; + return url.toString(); + } catch { + return raw; + } +} + +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, reason: 'auth_required', expected: expectedName, actual: actualName, url: actualThreadUrl }; + } + + if (probe?.searchFailure || /we didn't find anything|no results found|no results for/i.test(bodyText)) { + return { ok: false, reason: 'search_failure_visible', expected: expectedName, actual: actualName, url: actualThreadUrl }; + } + + if (expectedThreadUrl && actualThreadUrl && expectedThreadUrl !== actualThreadUrl) { + return { ok: false, reason: 'thread_url_mismatch', expected: expectedThreadUrl, actual: actualThreadUrl, url: actualThreadUrl }; + } + + if (!actualName || normalizeName(actualName) !== normalizeName(expectedName)) { + return { ok: false, reason: 'recipient_header_mismatch', expected: expectedName, actual: actualName, url: actualThreadUrl }; + } + + if (!probe?.composerFound) { + return { ok: false, reason: 'composer_not_found', expected: expectedName, actual: actualName, url: actualThreadUrl }; + } + + const expectedLastHash = normalizeWhitespace(expected.expectedLastHash); + if (expectedLastHash && expectedLastHash !== probe?.latestMessageHash) { + return { ok: false, reason: 'latest_message_mismatch', expected: expectedLastHash, actual: probe?.latestMessageHash || '', url: actualThreadUrl }; + } + + const expectedLastText = normalizeWhitespace(expected.expectedLastText); + if (expectedLastText && !textContainsNormalized(bodyText, expectedLastText)) { + return { ok: false, reason: 'latest_message_mismatch', expected: expectedLastText, actual: '', url: actualThreadUrl }; + } + + return { ok: true, reason: 'verified', expected: expectedName, actual: actualName, url: actualThreadUrl }; +} + +function requireStringArg(args, key, label = key) { + const value = normalizeWhitespace(args[key]); + if (!value) throw new ArgumentError(`${label} is required`); + return value; +} + +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 = 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', 'expected', 'actual', 'url', 'screenshot', 'authRequired', 'bodyText', 'composerFound', 'composerText', 'headerNames', 'latestMessageHash', 'latestMessageText', 'searchFailure', 'title'], + func: async (page, args) => { + if (!page) throw new CommandExecutionError('Browser session required for linkedin safe-send'); + + const threadUrl = requireStringArg(args, '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.reason === '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.expected}; actual ${safety.actual || 'not_visible'} at ${safety.url || '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.reason}`, + 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.actual, + reason: safety.reason, + thread_url: safety.url, + message_chars: message.length, + screenshot: screenshot ? 'captured' : '', + }]; + } + + const focus = 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.3); + + const composer = 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.reason}`); + } + + const sent = await page.evaluate(buildClickSendScript()); + if (!sent?.ok || !sent.sent) { + throw new CommandExecutionError(`LinkedIn safe-send blocked: ${sent?.error || 'send_click_failed'}`); + } + + await page.wait(1); + return [{ + status: 'sent', + recipient: safety.actual, + reason: safety.reason, + thread_url: safety.url, + message_chars: message.length, + screenshot: screenshot ? 'captured' : '', + }]; + }, +}); + +export const __test__ = { + normalizeWhitespace, + normalizeName, + canonicalizeLinkedInThreadUrl, + hashText, + assessThreadSafety, +}; diff --git a/clis/linkedin/safe-send.test.js b/clis/linkedin/safe-send.test.js new file mode 100644 index 000000000..69e257b10 --- /dev/null +++ b/clis/linkedin/safe-send.test.js @@ -0,0 +1,184 @@ +import { describe, expect, it, vi } from 'vitest'; +import { getRegistry } from '@jackwener/opencli/registry'; +import { CommandExecutionError } from '@jackwener/opencli/errors'; +import './safe-send.js'; + +const { + normalizeWhitespace, + normalizeName, + canonicalizeLinkedInThreadUrl, + hashText, + assessThreadSafety, +} = await import('./safe-send.js').then((m) => m.__test__); + +function makeFakePage(probe) { + return { + goto: vi.fn(async () => undefined), + wait: vi.fn(async () => undefined), + evaluate: vi.fn(async (script) => { + const text = String(script); + if (text.includes('__OPENCLI_LINKEDIN_PROBE__')) return probe; + if (text.includes('__OPENCLI_LINKEDIN_FOCUS_COMPOSER__')) return { ok: true, composerText: '' }; + if (text.includes('__OPENCLI_LINKEDIN_READ_COMPOSER__')) return { ok: true, composerText: probe.composerText || '' }; + if (text.includes('__OPENCLI_LINKEDIN_CLICK_SEND__')) return { ok: true, sent: true }; + return undefined; + }), + insertText: vi.fn(async () => undefined), + pressKey: vi.fn(async () => undefined), + screenshot: vi.fn(async () => 'base64-screenshot'), + }; +} + +describe('linkedin safe-send helpers', () => { + it('normalizes whitespace and LinkedIn names for exact-ish comparisons', () => { + expect(normalizeWhitespace(' Lokesh\n\tRamesh ')).toBe('Lokesh Ramesh'); + expect(normalizeName('Lokesh Ramesh • 1st')).toBe('lokesh ramesh'); + }); + + it('canonicalizes thread URLs while dropping query and hash noise', () => { + expect(canonicalizeLinkedInThreadUrl('https://www.linkedin.com/messaging/thread/abc/?foo=1#bar')) + .toBe('https://www.linkedin.com/messaging/thread/abc/'); + }); + + it('fails closed when LinkedIn search produced no results even if a composer is visible', () => { + const result = assessThreadSafety({ + url: 'https://www.linkedin.com/messaging/thread/bora/', + headerNames: ['Bora Nicholson'], + bodyText: "We didn't find anything for Victoria Munoz\nBora Nicholson", + searchFailure: true, + composerFound: true, + latestMessageHash: hashText('hello'), + }, { + expectedName: 'Victoria Munoz', + threadUrl: 'https://www.linkedin.com/messaging/thread/victoria/', + expectedLastText: 'hello', + }); + + expect(result.ok).toBe(false); + expect(result.reason).toBe('search_failure_visible'); + }); + + it('fails closed on recipient header mismatch', () => { + const result = assessThreadSafety({ + url: 'https://www.linkedin.com/messaging/thread/bora/', + headerNames: ['Bora Nicholson'], + bodyText: 'Bora Nicholson\nhello', + composerFound: true, + latestMessageHash: hashText('hello'), + }, { + expectedName: 'Victoria Munoz', + expectedLastText: 'hello', + }); + + expect(result.ok).toBe(false); + expect(result.reason).toBe('recipient_header_mismatch'); + expect(result.actual).toBe('Bora Nicholson'); + }); + + it('fails closed when the stored latest message is no longer visible', () => { + const result = assessThreadSafety({ + url: 'https://www.linkedin.com/messaging/thread/lokesh/', + headerNames: ['Lokesh Ramesh'], + bodyText: 'Lokesh Ramesh\na newer inbound arrived', + composerFound: true, + latestMessageHash: hashText('a newer inbound arrived'), + }, { + expectedName: 'Lokesh Ramesh', + expectedLastText: 'old inbound text', + }); + + expect(result.ok).toBe(false); + expect(result.reason).toBe('latest_message_mismatch'); + }); + + it('passes only when recipient, thread, latest text, and composer are all verified', () => { + const result = assessThreadSafety({ + url: 'https://www.linkedin.com/messaging/thread/lokesh/?mini=true', + headerNames: ['Lokesh Ramesh'], + bodyText: 'Lokesh Ramesh\nI think outside help would fit best for provider doc follow ups', + composerFound: true, + latestMessageHash: hashText('I think outside help would fit best for provider doc follow ups'), + }, { + expectedName: 'Lokesh Ramesh', + threadUrl: 'https://www.linkedin.com/messaging/thread/lokesh/', + expectedLastText: 'provider doc follow ups', + }); + + expect(result.ok).toBe(true); + expect(result.reason).toBe('verified'); + }); +}); + +describe('linkedin safe-send command', () => { + it('registers as a write command with safe output columns', () => { + const command = getRegistry().get('linkedin/safe-send'); + expect(command).toBeDefined(); + expect(command.access).toBe('write'); + expect(command.columns).toEqual(expect.arrayContaining(['status', 'recipient', 'reason'])); + }); + + it('does not type or send when verification fails', async () => { + const command = getRegistry().get('linkedin/safe-send'); + const page = makeFakePage({ + url: 'https://www.linkedin.com/messaging/thread/bora/', + headerNames: ['Bora Nicholson'], + bodyText: 'Bora Nicholson', + composerFound: true, + searchFailure: false, + }); + + await expect(command.func(page, { + 'thread-url': 'https://www.linkedin.com/messaging/thread/victoria/', + 'expected-name': 'Victoria Munoz', + message: 'hello victoria', + send: true, + })).rejects.toBeInstanceOf(CommandExecutionError); + + expect(page.insertText).not.toHaveBeenCalled(); + expect(page.pressKey).not.toHaveBeenCalled(); + }); + + it('dry-runs by default after verification without filling or sending', async () => { + const command = getRegistry().get('linkedin/safe-send'); + const page = makeFakePage({ + url: 'https://www.linkedin.com/messaging/thread/lokesh/', + headerNames: ['Lokesh Ramesh'], + bodyText: 'Lokesh Ramesh\nprovider doc follow ups', + composerFound: true, + searchFailure: false, + }); + + const rows = await command.func(page, { + 'thread-url': 'https://www.linkedin.com/messaging/thread/lokesh/', + 'expected-name': 'Lokesh Ramesh', + message: 'both, but starting hands on', + }); + + expect(rows[0]).toMatchObject({ status: 'verified_dry_run', recipient: 'Lokesh Ramesh', reason: 'verified' }); + expect(page.insertText).not.toHaveBeenCalled(); + expect(page.pressKey).not.toHaveBeenCalled(); + }); + + it('fills and sends only when --send is explicitly true and post-fill verification matches exactly', async () => { + const command = getRegistry().get('linkedin/safe-send'); + const page = makeFakePage({ + url: 'https://www.linkedin.com/messaging/thread/lokesh/', + headerNames: ['Lokesh Ramesh'], + bodyText: 'Lokesh Ramesh\nprovider doc follow ups', + composerFound: true, + searchFailure: false, + composerText: 'both, but starting hands on', + }); + + const rows = await command.func(page, { + 'thread-url': 'https://www.linkedin.com/messaging/thread/lokesh/', + 'expected-name': 'Lokesh Ramesh', + message: 'both, but starting hands on', + send: true, + }); + + expect(rows[0]).toMatchObject({ status: 'sent', recipient: 'Lokesh Ramesh', reason: 'verified' }); + expect(page.insertText).toHaveBeenCalledWith('both, but starting hands on'); + expect(page.pressKey).not.toHaveBeenCalled(); + }); +}); diff --git a/clis/linkedin/thread-snapshot.js b/clis/linkedin/thread-snapshot.js new file mode 100644 index 000000000..bd9ff62b5 --- /dev/null +++ b/clis/linkedin/thread-snapshot.js @@ -0,0 +1,180 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors'; + +const LINKEDIN_DOMAIN = 'www.linkedin.com'; + +function normalizeWhitespace(value) { + return String(value ?? '').replace(/[\u00a0\u202f]/g, ' ').replace(/\s+/g, ' ').trim(); +} + +function canonicalizeLinkedInThreadUrl(value) { + const raw = normalizeWhitespace(value); + if (!raw) return ''; + try { + const url = new URL(raw); + if (!/linkedin\.com$/i.test(url.hostname) && !/\.linkedin\.com$/i.test(url.hostname)) return raw; + url.hash = ''; + url.search = ''; + if (!url.pathname.endsWith('/')) url.pathname += '/'; + return url.toString(); + } catch { + return raw; + } +} + +function requireStringArg(args, key, label = key) { + const value = normalizeWhitespace(args[key]); + if (!value) throw new ArgumentError(`${label} is required`); + return value; +} + +function buildThreadSnapshotScript(maxScrolls) { + const scrolls = Number.isFinite(Number(maxScrolls)) ? Math.max(0, Math.min(80, Number(maxScrolls))) : 30; + return String.raw`(async () => { + const marker = '__OPENCLI_LINKEDIN_THREAD_SNAPSHOT__'; + void marker; + const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + const clean = (s) => String(s || '').replace(/[\u00a0\u202f]/g, ' ').replace(/\s+/g, ' ').trim(); + const text = document.body ? (document.body.innerText || '') : ''; + 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 selectors = [ + '.msg-s-message-list', + '.msg-s-message-list-scrollable', + '.msg-thread', + 'main [role="main"]', + 'main' + ]; + let scroller = null; + for (const selector of selectors) { + const el = document.querySelector(selector); + if (el && (el.scrollHeight > el.clientHeight || selector === 'main')) { scroller = el; break; } + } + scroller = scroller || document.scrollingElement || document.documentElement; + let previousHeight = -1; + let stable = 0; + for (let i = 0; i < ${scrolls}; i += 1) { + scroller.scrollTop = 0; + window.scrollTo(0, 0); + await sleep(750); + const height = scroller.scrollHeight || document.body.scrollHeight || 0; + if (height === previousHeight) stable += 1; else stable = 0; + previousHeight = height; + if (stable >= 3) break; + } + await sleep(1000); + + const headerCandidates = []; + const headerSelectors = [ + '.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 headerSelectors) { + 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 seen = new Set(); + const messages = []; + const nodes = Array.from(document.querySelectorAll('.msg-s-message-list__event, .msg-s-event-listitem, [data-event-urn], .msg-s-message-list-content')); + for (const [nodeIndex, el] of nodes.entries()) { + const raw = clean(el.innerText || el.textContent); + if (!raw || seen.has(raw)) continue; + seen.add(raw); + const lines = raw.split(/\n+/).map(clean).filter(Boolean); + const speaker = lines.length > 1 && lines[0].length <= 120 ? lines[0] : ''; + messages.push({ index: messages.length, nodeIndex, speaker, text: raw }); + } + + const refreshedText = document.body ? (document.body.innerText || '') : ''; + const fallbackLines = refreshedText.split(/\n+/).map(clean).filter(Boolean); + const latestMessageText = messages.length + ? messages[messages.length - 1].text + : ([...fallbackLines].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: refreshedText, + latestMessageText, + messages, + messageCount: messages.length, + authRequired, + extractedAt: new Date().toISOString(), + maxScrolls: ${scrolls} + }; + })()`; +} + +cli({ + site: 'linkedin', + name: 'thread-snapshot', + access: 'read', + description: 'Load a LinkedIn messaging thread, scroll for available history, and return a full context snapshot', + domain: LINKEDIN_DOMAIN, + strategy: Strategy.UI, + browser: true, + args: [ + { name: 'thread-url', required: true, help: 'Exact LinkedIn messaging thread URL to open and snapshot' }, + { name: 'max-scrolls', type: 'number', default: 30, help: 'Maximum upward scroll attempts to load older messages' }, + { name: 'json', type: 'bool', default: false, help: 'Return only JSON snapshot string in the snapshot_json field' }, + ], + columns: ['thread_url', 'recipient', 'message_count', 'latest_text', 'snapshot_json'], + func: async (page, args) => { + if (!page) throw new CommandExecutionError('Browser session required for linkedin thread-snapshot'); + const threadUrl = canonicalizeLinkedInThreadUrl(requireStringArg(args, 'thread-url', '--thread-url')); + + await page.goto('https://www.linkedin.com/messaging/'); + await page.wait(4); + await page.goto(threadUrl); + await page.wait(10); + + const snapshot = await page.evaluate(buildThreadSnapshotScript(args['max-scrolls'])); + if (snapshot?.authRequired) { + throw new AuthRequiredError(LINKEDIN_DOMAIN, 'LinkedIn thread-snapshot requires an active signed-in LinkedIn browser session.'); + } + + const actualUrl = canonicalizeLinkedInThreadUrl(snapshot?.url || ''); + if (threadUrl && actualUrl && threadUrl !== actualUrl) { + throw new CommandExecutionError('LinkedIn thread-snapshot blocked: thread_url_mismatch', `Expected ${threadUrl}; actual ${actualUrl}`); + } + + const recipient = normalizeWhitespace((snapshot?.headerNames || [])[0] || ''); + const messageCount = Array.isArray(snapshot?.messages) ? snapshot.messages.length : 0; + const normalized = { + ...(snapshot || {}), + url: actualUrl || threadUrl, + headerNames: snapshot?.headerNames || [], + latestMessageText: normalizeWhitespace(snapshot?.latestMessageText || ''), + messages: Array.isArray(snapshot?.messages) ? snapshot.messages : [], + }; + + return [{ + thread_url: normalized.url, + recipient, + message_count: messageCount, + latest_text: normalized.latestMessageText, + snapshot_json: JSON.stringify(normalized), + }]; + }, +}); + +export const __test__ = { + normalizeWhitespace, + canonicalizeLinkedInThreadUrl, + buildThreadSnapshotScript, +}; diff --git a/clis/linkedin/thread-snapshot.test.js b/clis/linkedin/thread-snapshot.test.js new file mode 100644 index 000000000..e88691888 --- /dev/null +++ b/clis/linkedin/thread-snapshot.test.js @@ -0,0 +1,49 @@ +import { describe, expect, it, vi } from 'vitest'; +import { getRegistry } from '@jackwener/opencli/registry'; +import './thread-snapshot.js'; + +function makeFakePage(snapshot) { + return { + goto: vi.fn(async () => undefined), + wait: vi.fn(async () => undefined), + evaluate: vi.fn(async () => snapshot), + }; +} + +describe('linkedin thread-snapshot command', () => { + it('registers as a read command for loading full thread context', () => { + const command = getRegistry().get('linkedin/thread-snapshot'); + expect(command).toBeDefined(); + expect(command.access).toBe('read'); + expect(command.columns).toEqual(expect.arrayContaining(['thread_url', 'recipient', 'message_count', 'latest_text'])); + }); + + it('opens messaging first, then exact thread, and returns extracted messages', async () => { + const command = getRegistry().get('linkedin/thread-snapshot'); + const page = makeFakePage({ + url: 'https://www.linkedin.com/messaging/thread/abc/', + headerNames: ['Neha Rudraraju'], + latestMessageText: 'safe-send test from hermes. pls ignore :)', + messages: [ + { index: 0, speaker: 'Neha Rudraraju', text: 'damn i just saw ur msg sry sry' }, + { index: 1, speaker: 'Me', text: 'safe-send test from hermes. pls ignore :)' }, + ], + }); + + const rows = await command.func(page, { + 'thread-url': 'https://www.linkedin.com/messaging/thread/abc/', + 'max-scrolls': 8, + json: false, + }); + + expect(page.goto).toHaveBeenNthCalledWith(1, 'https://www.linkedin.com/messaging/'); + expect(page.goto).toHaveBeenNthCalledWith(2, 'https://www.linkedin.com/messaging/thread/abc/'); + expect(rows[0]).toMatchObject({ + thread_url: 'https://www.linkedin.com/messaging/thread/abc/', + recipient: 'Neha Rudraraju', + message_count: 2, + latest_text: 'safe-send test from hermes. pls ignore :)', + }); + expect(rows[0].snapshot_json).toContain('damn i just saw ur msg sry sry'); + }); +});