import * as fs from 'node:fs'; import * as path from 'node:path'; import { CommandExecutionError } from '@jackwener/opencli/errors'; import { cli, Strategy } from '@jackwener/opencli/registry'; import { parseTweetUrl } from './shared.js'; import { COMPOSER_FILE_INPUT_SELECTOR, attachComposerImage, downloadRemoteImage, 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 // accepted any host). parseTweetUrl bubbles ArgumentError on // malformed/off-domain inputs. const target = parseTweetUrl(rawUrl); return `https://x.com/compose/post?in_reply_to=${target.id}`; } 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); const boxes = Array.from(document.querySelectorAll('[data-testid="tweetTextarea_0"]')); const box = boxes.find(visible) || boxes[0]; if (!box) { return { ok: false, message: 'Could not find the reply text area. Are you logged in?' }; } box.focus(); const textToInsert = ${JSON.stringify(text)}; // execCommand('insertText') is more reliable with Twitter's Draft.js editor if (!document.execCommand('insertText', false, textToInsert)) { // Fallback to paste event if execCommand fails const dataTransfer = new DataTransfer(); dataTransfer.setData('text/plain', textToInsert); box.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dataTransfer, bubbles: true, cancelable: true })); } 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) { const iterations = Math.ceil(SUBMIT_TIMEOUT_MS / SUBMIT_POLL_MS); return page.evaluate(`(async () => { try { const visible = (el) => !!el && (el.offsetParent !== null || el.getClientRects().length > 0); for (let i = 0; i < ${JSON.stringify(iterations)}; i++) { const buttons = Array.from( document.querySelectorAll('[data-testid="tweetButton"], [data-testid="tweetButtonInline"]') ); const btn = buttons.find((el) => visible(el) && !el.disabled && el.getAttribute('aria-disabled') !== 'true'); if (btn) { btn.click(); return { ok: true }; } await new Promise(r => setTimeout(r, ${JSON.stringify(SUBMIT_POLL_MS)})); } return { ok: false, message: 'Reply button is disabled or not found.' }; } 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', access: 'write', description: 'Reply to a specific tweet, optionally with a local or remote image', domain: 'x.com', strategy: Strategy.UI, // Uses the UI directly to input and click post browser: true, args: [ { name: 'url', type: 'string', required: true, positional: true, help: 'The URL of the tweet to reply to' }, { name: 'text', type: 'string', required: true, positional: true, help: 'The text content of your reply' }, { 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', 'url'], func: async (page, kwargs) => { if (!page) throw new CommandExecutionError('Browser session required for twitter reply'); if (kwargs.image && kwargs['image-url']) { throw new CommandExecutionError('Use either --image or --image-url, not both.'); } let localImagePath; let cleanupDir; try { if (kwargs.image) { localImagePath = resolveImagePath(kwargs.image); } else if (kwargs['image-url']) { const downloaded = await downloadRemoteImage(kwargs['image-url']); localImagePath = downloaded.absPath; cleanupDir = downloaded.cleanupDir; } // 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); 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'] } : {}), }]; } finally { if (cleanupDir) { fs.rmSync(cleanupDir, { recursive: true, force: true }); } } } }); export const __test__ = { buildReplyComposerUrl, isPromiseCollectedError, };