diff --git a/cli-manifest.json b/cli-manifest.json index c3caa8d85..b9ffb12b8 100644 --- a/cli-manifest.json +++ b/cli-manifest.json @@ -23372,7 +23372,8 @@ "columns": [ "status", "message", - "text" + "text", + "url" ], "type": "js", "modulePath": "twitter/reply.js", diff --git a/clis/twitter/reply.js b/clis/twitter/reply.js index 1ed441099..ef702961d 100644 --- a/clis/twitter/reply.js +++ b/clis/twitter/reply.js @@ -10,6 +10,10 @@ import { resolveImagePath, } from './utils.js'; +const COMPOSER_SELECTOR = '[data-testid="tweetTextarea_0"]'; +const SUBMIT_POLL_MS = 500; +const SUBMIT_TIMEOUT_MS = 15_000; + function buildReplyComposerUrl(rawUrl) { // Replaces the legacy local extractTweetId which used `/\/status\/(\d+)/` // (silent: matched `/status/1234567` on substring `/status/123` and @@ -19,7 +23,36 @@ function buildReplyComposerUrl(rawUrl) { return `https://x.com/compose/post?in_reply_to=${target.id}`; } -async function submitReply(page, text) { +function isPromiseCollectedError(err) { + const msg = err instanceof Error ? err.message : String(err); + return msg.includes('Promise was collected'); +} + +async function openReplyComposer(page, rawUrl) { + await page.goto(buildReplyComposerUrl(rawUrl), { waitUntil: 'load', settleMs: 2500 }); + try { + await page.wait({ selector: COMPOSER_SELECTOR, timeout: 15 }); + return { ok: true }; + } catch { + // X sometimes leaves /compose/post?in_reply_to= on the Home + // timeline behind a loading dialog. Fall back to the canonical tweet + // page and click the visible Reply action there. + await page.goto(rawUrl, { waitUntil: 'load', settleMs: 2500 }); + const clicked = await page.evaluate(`(() => { + const visible = (el) => !!el && (el.offsetParent !== null || el.getClientRects().length > 0); + const buttons = Array.from(document.querySelectorAll('[data-testid="reply"]')); + const btn = buttons.find((el) => visible(el) && !el.disabled && el.getAttribute('aria-disabled') !== 'true'); + if (!btn) return { ok: false, message: 'Could not find the reply button on the target tweet.' }; + btn.click(); + return { ok: true }; + })()`); + if (!clicked?.ok) return clicked; + await page.wait({ selector: COMPOSER_SELECTOR, timeout: 15 }); + return { ok: true }; + } +} + +async function insertReplyText(page, text) { return page.evaluate(`(async () => { try { const visible = (el) => !!el && (el.offsetParent !== null || el.getClientRects().length > 0); @@ -44,23 +77,109 @@ async function submitReply(page, text) { } await new Promise(r => setTimeout(r, 1000)); + const normalize = s => String(s || '').replace(/\\u00a0/g, ' ').replace(/\\s+/g, ' ').trim(); + const actual = box.innerText || box.textContent || ''; + if (!normalize(actual).includes(normalize(textToInsert))) { + return { ok: false, message: 'Could not verify reply text in the composer after typing.', actualText: actual }; + } + return { ok: true }; + } catch (e) { + return { ok: false, message: e.toString() }; + } + })()`); +} +async function clickReplyButton(page) { + return page.evaluate(`(() => { + try { + const visible = (el) => !!el && (el.offsetParent !== null || el.getClientRects().length > 0); const buttons = Array.from( document.querySelectorAll('[data-testid="tweetButton"], [data-testid="tweetButtonInline"]') ); - const btn = buttons.find((el) => visible(el) && !el.disabled); + const btn = buttons.find((el) => visible(el) && !el.disabled && el.getAttribute('aria-disabled') !== 'true'); if (!btn) { return { ok: false, message: 'Reply button is disabled or not found.' }; } btn.click(); - return { ok: true, message: 'Reply posted successfully.' }; + return { ok: true }; } catch (e) { return { ok: false, message: e.toString() }; } })()`); } +async function detectReplySent(page) { + return page.evaluate(`(() => { + const visible = (el) => !!el && (el.offsetParent !== null || el.getClientRects().length > 0); + const toasts = Array.from(document.querySelectorAll('[role="alert"], [data-testid="toast"]')) + .filter((el) => visible(el)); + const successToast = toasts.find((el) => /sent|posted|your post was sent|your tweet was sent/i.test(el.textContent || '')); + if (!successToast) return { ok: false }; + const link = successToast.querySelector('a[href*="/status/"]'); + return { + ok: true, + message: 'Reply posted successfully.', + url: link?.href || link?.getAttribute('href') || undefined + }; + })()`); +} + +async function waitForReplySent(page, text) { + const iterations = Math.ceil(SUBMIT_TIMEOUT_MS / SUBMIT_POLL_MS); + try { + return await page.evaluate(`(async () => { + const expected = ${JSON.stringify(text)}; + const normalize = s => String(s || '').replace(/\\u00a0/g, ' ').replace(/\\s+/g, ' ').trim(); + const expectedText = normalize(expected); + const visible = (el) => !!el && (el.offsetParent !== null || el.getClientRects().length > 0); + for (let i = 0; i < ${JSON.stringify(iterations)}; i++) { + await new Promise(r => setTimeout(r, ${JSON.stringify(SUBMIT_POLL_MS)})); + const toasts = Array.from(document.querySelectorAll('[role="alert"], [data-testid="toast"]')) + .filter((el) => visible(el)); + const successToast = toasts.find((el) => /sent|posted|your post was sent|your tweet was sent/i.test(el.textContent || '')); + if (successToast) { + const link = successToast.querySelector('a[href*="/status/"]'); + return { + ok: true, + message: 'Reply posted successfully.', + url: link?.href || link?.getAttribute('href') || undefined + }; + } + const alert = toasts.find((el) => /failed|error|try again|not sent|could not/i.test(el.textContent || '')); + if (alert) return { ok: false, message: (alert.textContent || 'Reply failed to post.').trim() }; + + const boxes = Array.from(document.querySelectorAll('[data-testid="tweetTextarea_0"]')).filter(visible); + const composerStillHasText = boxes.some((box) => normalize(box.innerText || box.textContent || '').includes(expectedText)); + if (!composerStillHasText) return { ok: true, message: 'Reply posted successfully.' }; + } + return { ok: false, message: 'Reply submission did not complete before timeout.' }; + })()`); + } catch (err) { + // X may route the SPA immediately after click, making CDP collect the + // polling promise even though the reply was submitted. If the page now + // shows the success toast, report success instead of a false negative. + if (!isPromiseCollectedError(err)) throw err; + await page.wait(2); + const recovered = await detectReplySent(page); + if (recovered?.ok) return recovered; + throw err; + } +} + +async function submitReply(page, text) { + const typed = await insertReplyText(page, text); + if (!typed?.ok) return typed; + let clicked; + try { + clicked = await clickReplyButton(page); + } catch (err) { + if (!isPromiseCollectedError(err)) throw err; + } + if (clicked && !clicked.ok) return clicked; + return waitForReplySent(page, text); +} + cli({ site: 'twitter', name: 'reply', @@ -75,7 +194,7 @@ cli({ { name: 'image', help: 'Optional local image path to attach to the reply' }, { name: 'image-url', help: 'Optional remote image URL to download and attach to the reply' }, ], - columns: ['status', 'message', 'text'], + columns: ['status', 'message', 'text', 'url'], func: async (page, kwargs) => { if (!page) throw new CommandExecutionError('Browser session required for twitter reply'); @@ -92,21 +211,24 @@ cli({ localImagePath = downloaded.absPath; cleanupDir = downloaded.cleanupDir; } - // Dedicated composer is more reliable than the inline tweet page reply box. - await page.goto(buildReplyComposerUrl(kwargs.url), { waitUntil: 'load', settleMs: 2500 }); - await page.wait({ selector: '[data-testid="tweetTextarea_0"]', timeout: 15 }); + // Dedicated composer is normally more reliable than the inline + // tweet page reply box, but X occasionally leaves that route on the + // Home timeline behind a loading dialog. openReplyComposer falls + // back to the target tweet's visible Reply action. + const composer = await openReplyComposer(page, kwargs.url); + if (!composer?.ok) { + return [{ status: 'failed', message: composer?.message ?? 'Could not open the reply composer.', text: kwargs.text }]; + } if (localImagePath) { await page.wait({ selector: COMPOSER_FILE_INPUT_SELECTOR, timeout: 20 }); await attachComposerImage(page, localImagePath); } const result = await submitReply(page, kwargs.text); - if (result.ok) { - await page.wait(3); // Wait for network submission to complete - } return [{ status: result.ok ? 'success' : 'failed', message: result.message, text: kwargs.text, + ...(result.url ? { url: result.url } : {}), ...(kwargs.image ? { image: kwargs.image } : {}), ...(kwargs['image-url'] ? { 'image-url': kwargs['image-url'] } : {}), }]; @@ -119,4 +241,5 @@ cli({ }); export const __test__ = { buildReplyComposerUrl, + isPromiseCollectedError, }; diff --git a/clis/twitter/reply.test.js b/clis/twitter/reply.test.js index 9a7c4f03b..1ee6d7c3e 100644 --- a/clis/twitter/reply.test.js +++ b/clis/twitter/reply.test.js @@ -13,6 +13,8 @@ describe('twitter reply command', () => { const cmd = getRegistry().get('twitter/reply'); expect(cmd?.func).toBeTypeOf('function'); const page = createPageMock([ + { ok: true }, + { ok: true }, { ok: true, message: 'Reply posted successfully.' }, ]); const result = await cmd.func(page, { @@ -38,6 +40,8 @@ describe('twitter reply command', () => { const setFileInput = vi.fn().mockResolvedValue(undefined); const page = createPageMock([ { ok: true, previewCount: 1 }, + { ok: true }, + { ok: true }, { ok: true, message: 'Reply posted successfully.' }, ], { setFileInput, @@ -74,6 +78,8 @@ describe('twitter reply command', () => { const setFileInput = vi.fn().mockResolvedValue(undefined); const page = createPageMock([ { ok: true, previewCount: 1 }, + { ok: true }, + { ok: true }, { ok: true, message: 'Reply posted successfully.' }, ], { setFileInput, @@ -102,6 +108,55 @@ describe('twitter reply command', () => { ]); vi.unstubAllGlobals(); }); + it('falls back to the target tweet page when the dedicated composer route does not expose a textarea', async () => { + const cmd = getRegistry().get('twitter/reply'); + expect(cmd?.func).toBeTypeOf('function'); + const wait = vi.fn() + .mockRejectedValueOnce(new Error('Selector not found: [data-testid="tweetTextarea_0"]')) + .mockResolvedValue(undefined); + const page = createPageMock([ + { ok: true }, // click target tweet page Reply button + { ok: true }, // insert reply text + { ok: true }, // click composer Reply button + { ok: true, message: 'Reply posted successfully.' }, // submit completed + ], { wait }); + + const url = 'https://x.com/_kop6/status/2040254679301718161?s=20'; + const result = await cmd.func(page, { url, text: 'fallback reply' }); + + expect(page.goto).toHaveBeenNthCalledWith(1, 'https://x.com/compose/post?in_reply_to=2040254679301718161', { waitUntil: 'load', settleMs: 2500 }); + expect(page.goto).toHaveBeenNthCalledWith(2, url, { waitUntil: 'load', settleMs: 2500 }); + expect(page.evaluate.mock.calls[0][0]).toContain('[data-testid="reply"]'); + expect(wait).toHaveBeenLastCalledWith({ selector: '[data-testid="tweetTextarea_0"]', timeout: 15 }); + expect(result).toEqual([{ status: 'success', message: 'Reply posted successfully.', text: 'fallback reply' }]); + }); + it('treats an X success toast as success after a Promise was collected error', async () => { + const cmd = getRegistry().get('twitter/reply'); + expect(cmd?.func).toBeTypeOf('function'); + const evaluate = vi.fn() + .mockResolvedValueOnce({ ok: true }) // insert reply text + .mockResolvedValueOnce({ ok: true }) // click Reply + .mockRejectedValueOnce(new Error('{"code":-32000,"message":"Promise was collected"}')) + .mockResolvedValueOnce({ + ok: true, + message: 'Reply posted successfully.', + url: 'https://x.com/me/status/123', + }); + const page = createPageMock([], { evaluate }); + + const result = await cmd.func(page, { + url: 'https://x.com/_kop6/status/2040254679301718161?s=20', + text: 'toast recovery', + }); + + expect(page.wait).toHaveBeenCalledWith(2); + expect(result).toEqual([{ + status: 'success', + message: 'Reply posted successfully.', + text: 'toast recovery', + url: 'https://x.com/me/status/123', + }]); + }); it('rejects using --image and --image-url together', async () => { const cmd = getRegistry().get('twitter/reply'); expect(cmd?.func).toBeTypeOf('function');