From 1641df864c696ff73b54bc438cb0e432800d5628 Mon Sep 17 00:00:00 2001 From: Gaurav Saxena Date: Mon, 1 Jun 2026 15:26:02 +0530 Subject: [PATCH] fix(linkedin): preserve safe-send multiline messages --- clis/linkedin/safe-send.js | 107 +++++++++++++++++++++++++++++++- clis/linkedin/safe-send.test.js | 51 +++++++++++++++ 2 files changed, 156 insertions(+), 2 deletions(-) diff --git a/clis/linkedin/safe-send.js b/clis/linkedin/safe-send.js index 967161278..48d8457b8 100644 --- a/clis/linkedin/safe-send.js +++ b/clis/linkedin/safe-send.js @@ -53,6 +53,10 @@ function textContainsNormalized(haystack, needle) { return !n || h.includes(n); } +function hasLineBreaks(value) { + return /\r|\n/.test(String(value ?? '')); +} + function selectBestHeaderName(headerNames, expectedName) { const expected = normalizeName(expectedName); const names = (Array.isArray(headerNames) ? headerNames : []) @@ -107,6 +111,12 @@ function requireStringArg(args, key, label = key) { return value; } +function requireRawStringArg(args, key, label = key) { + const value = String(args[key] ?? ''); + if (!normalizeWhitespace(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// URL`); @@ -198,6 +208,78 @@ function buildReadComposerScript() { })()`; } +function buildFillComposerMultilineScript(message) { + return `(() => { + const marker = '__OPENCLI_LINKEDIN_FILL_COMPOSER_MULTILINE__'; + void marker; + const message = ${JSON.stringify(String(message ?? ''))}; + const normalize = (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: '', method: '' }; + + const setDomMultiline = () => { + composer.innerHTML = ''; + const normalized = message.replace(/\\r\\n/g, '\\n').replace(/\\r/g, '\\n'); + const lines = normalized.split('\\n'); + lines.forEach((line, index) => { + if (index > 0) composer.appendChild(document.createElement('br')); + if (line) composer.appendChild(document.createTextNode(line)); + }); + composer.dispatchEvent(new InputEvent('beforeinput', { + bubbles: true, + cancelable: true, + inputType: 'insertFromPaste', + data: message, + })); + composer.dispatchEvent(new InputEvent('input', { + bubbles: true, + inputType: 'insertFromPaste', + data: message, + })); + return { method: 'dom_multiline' }; + }; + + composer.focus(); + composer.innerHTML = ''; + composer.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'deleteContentBackward', data: null })); + + let method = 'dom_multiline'; + try { + if (typeof DataTransfer !== 'undefined' && typeof ClipboardEvent !== 'undefined') { + const data = new DataTransfer(); + data.setData('text/plain', message); + const paste = new ClipboardEvent('paste', { + clipboardData: data, + bubbles: true, + cancelable: true, + }); + composer.dispatchEvent(paste); + method = 'paste_event'; + } + } catch { + method = 'dom_multiline'; + } + + if (normalize(composer.innerText || composer.textContent) !== normalize(message)) { + method = setDomMultiline().method; + } + + const brCount = composer.querySelectorAll('br').length; + const childBlockCount = Array.from(composer.children).filter((el) => { + const tag = String(el.tagName || '').toLowerCase(); + return tag === 'br' || tag === 'div' || tag === 'p'; + }).length; + + return { + ok: normalize(composer.innerText || composer.textContent) === normalize(message), + composerText: composer.innerText || composer.textContent || '', + method, + renderedBreaks: brCount + childBlockCount, + }; + })()`; +} + function buildClickSendScript() { return String.raw`(() => { const marker = '__OPENCLI_LINKEDIN_CLICK_SEND__'; @@ -224,6 +306,24 @@ async function probeThread(page) { }; } +async function fillComposer(page, message) { + if (!hasLineBreaks(message)) { + await page.insertText(message); + return { method: 'insert_text', renderedBreaks: 0 }; + } + + const result = unwrapEvaluateResult(await page.evaluate(buildFillComposerMultilineScript(message))); + if (result?.ok) { + return { + method: result.method || 'dom_multiline', + renderedBreaks: Number(result.renderedBreaks || 0), + }; + } + + await page.insertText(message); + return { method: 'insert_text_fallback', renderedBreaks: 0 }; +} + cli({ site: 'linkedin', name: 'safe-send', @@ -247,7 +347,7 @@ cli({ 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'); + const message = requireRawStringArg(args, 'message', '--message'); await page.goto('https://www.linkedin.com/messaging/'); await page.wait(4); @@ -308,7 +408,7 @@ cli({ 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 fillComposer(page, message); await page.wait(0.6 + Math.random() * 0.8); const composer = unwrapEvaluateResult(await page.evaluate(buildReadComposerScript())); @@ -353,5 +453,8 @@ export const __test__ = { normalizeName, canonicalizeLinkedInThreadUrl, hashText, + hasLineBreaks, + requireRawStringArg, + buildFillComposerMultilineScript, assessThreadSafety, }; diff --git a/clis/linkedin/safe-send.test.js b/clis/linkedin/safe-send.test.js index 984c5a5c2..56639e363 100644 --- a/clis/linkedin/safe-send.test.js +++ b/clis/linkedin/safe-send.test.js @@ -8,6 +8,9 @@ const { normalizeName, canonicalizeLinkedInThreadUrl, hashText, + hasLineBreaks, + requireRawStringArg, + buildFillComposerMultilineScript, assessThreadSafety, } = await import('./safe-send.js').then((m) => m.__test__); @@ -20,6 +23,11 @@ function makeFakePage(probe) { 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_FILL_COMPOSER_MULTILINE__')) { + const match = text.match(/const message = (.*?);\n/); + composerText = match ? JSON.parse(match[1]) : composerText; + return { ok: true, composerText, method: 'dom_multiline', renderedBreaks: 2 }; + } if (text.includes('__OPENCLI_LINKEDIN_READ_COMPOSER__')) return { ok: true, composerText }; if (text.includes('__OPENCLI_LINKEDIN_CLICK_SEND__')) return { ok: true, sent: true }; return undefined; @@ -38,6 +46,26 @@ describe('linkedin safe-send helpers', () => { expect(normalizeName('Lokesh Ramesh • 1st')).toBe('lokesh ramesh'); }); + it('detects multiline messages without treating wrapped single-line text as multiline', () => { + expect(hasLineBreaks('hello\nworld')).toBe(true); + expect(hasLineBreaks('hello\r\nworld')).toBe(true); + expect(hasLineBreaks('hello world')).toBe(false); + }); + + it('validates required message arguments without stripping intentional formatting', () => { + expect(requireRawStringArg({ message: 'Hi\n\n- one' }, 'message', '--message')).toBe('Hi\n\n- one'); + expect(() => requireRawStringArg({ message: ' \n\t ' }, 'message', '--message')).toThrow(ArgumentError); + }); + + it('builds a LinkedIn-specific multiline composer script with paste and DOM fallback markers', () => { + const script = buildFillComposerMultilineScript('Hi\n\n- one\n- two'); + + expect(script).toContain('__OPENCLI_LINKEDIN_FILL_COMPOSER_MULTILINE__'); + expect(script).toContain('ClipboardEvent'); + expect(script).toContain('dom_multiline'); + expect(script).toContain('insertFromPaste'); + }); + 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/'); @@ -201,4 +229,27 @@ describe('linkedin safe-send command', () => { expect(page.insertText).toHaveBeenCalledWith('both, but starting hands on'); expect(page.pressKey).not.toHaveBeenCalled(); }); + + it('fills multiline messages through the LinkedIn composer helper before 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 message = 'Hi Lokesh\n\n- first point\n- second point'; + const rows = await command.func(page, { + 'thread-url': 'https://www.linkedin.com/messaging/thread/lokesh/', + 'expected-name': 'Lokesh Ramesh', + message, + send: true, + }); + + expect(rows[0]).toMatchObject({ status: 'sent', recipient: 'Lokesh Ramesh', reason: 'verified' }); + expect(page.insertText).not.toHaveBeenCalled(); + expect(page.evaluate).toHaveBeenCalledWith(expect.stringContaining('__OPENCLI_LINKEDIN_FILL_COMPOSER_MULTILINE__')); + }); });