import * as fs from 'node:fs'; import { CommandExecutionError } from '@jackwener/opencli/errors'; import { cli, Strategy } from '@jackwener/opencli/registry'; import { parseTweetUrl, buildTwitterArticleScopeSource } from './shared.js'; import { COMPOSER_FILE_INPUT_SELECTOR, attachComposerImage, downloadRemoteImage, resolveImagePath, } from './utils.js'; function buildQuoteComposerUrl(url) { // Twitter/X quote-tweet compose URL: the `url` param attaches the source // tweet as a quoted card. Validating tweet-id shape early surfaces obvious // typos before any browser interaction. const parsed = parseTweetUrl(url); return `https://x.com/compose/post?url=${encodeURIComponent(parsed.url)}`; } async function submitQuote(page, text, tweetId) { return page.evaluate(`(async () => { try { ${buildTwitterArticleScopeSource(tweetId)} 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 quote composer 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)); // Confirm the quoted card is rendered before submitting; otherwise // we may accidentally post a plain tweet without the quote // attachment. The compose page does not wrap the card in an //
, so we probe the document for any link whose path // exactly matches the requested status id (uses __twHasLinkToTarget // from buildTwitterArticleScopeSource). let cardAttempts = 0; let hasQuoteCard = false; while (cardAttempts < 20) { hasQuoteCard = __twHasLinkToTarget(document); if (hasQuoteCard) break; await new Promise(r => setTimeout(r, 250)); cardAttempts++; } if (!hasQuoteCard) { return { ok: false, message: 'Quote target did not render in the composer. The source tweet may be deleted or restricted.' }; } let btn = null; for (let i = 0; i < 30; i++) { const buttons = Array.from( document.querySelectorAll('[data-testid="tweetButton"], [data-testid="tweetButtonInline"]') ); btn = buttons.find((el) => visible(el) && !el.disabled && el.getAttribute('aria-disabled') !== 'true'); if (btn) break; await new Promise(r => setTimeout(r, 500)); } if (!btn) { return { ok: false, message: 'Tweet button is disabled or not found.' }; } btn.click(); const normalize = s => String(s || '').replace(/\\u00a0/g, ' ').replace(/\\s+/g, ' ').trim(); const expectedText = normalize(textToInsert); for (let i = 0; i < 30; i++) { await new Promise(r => setTimeout(r, 500)); 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: true, message: 'Quote tweet posted successfully.' }; 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 || 'Quote tweet failed to post.').trim() }; const visibleBoxes = Array.from(document.querySelectorAll('[data-testid="tweetTextarea_0"]')).filter(visible); const composerStillHasText = visibleBoxes.some((box) => normalize(box.innerText || box.textContent || '').includes(expectedText) ); if (!composerStillHasText) return { ok: true, message: 'Quote tweet posted successfully.' }; } return { ok: false, message: 'Quote tweet submission did not complete before timeout.' }; } catch (e) { return { ok: false, message: e.toString() }; } })()`); } cli({ site: 'twitter', name: 'quote', access: 'write', description: 'Quote-tweet a specific tweet with your own text, optionally with a local or remote image', domain: 'x.com', strategy: Strategy.UI, browser: true, args: [ { name: 'url', type: 'string', required: true, positional: true, help: 'The URL of the tweet to quote' }, { name: 'text', type: 'string', required: true, positional: true, help: 'The text content of your quote' }, { name: 'image', help: 'Optional local image path to attach to the quote tweet' }, { name: 'image-url', help: 'Optional remote image URL to download and attach to the quote tweet' }, ], columns: ['status', 'message', 'text'], func: async (page, kwargs) => { if (!page) throw new CommandExecutionError('Browser session required for twitter quote'); if (kwargs.image && kwargs['image-url']) { throw new CommandExecutionError('Use either --image or --image-url, not both.'); } // Validate URL (typed ArgumentError on malformed/off-domain inputs) // before any browser interaction or remote image download. const target = parseTweetUrl(kwargs.url); 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 more reliable than the inline quote-tweet button. await page.goto(`https://x.com/compose/post?url=${encodeURIComponent(target.url)}`, { waitUntil: 'load', settleMs: 2500 }); await page.wait({ selector: '[data-testid="tweetTextarea_0"]', timeout: 15 }); if (localImagePath) { await page.wait({ selector: COMPOSER_FILE_INPUT_SELECTOR, timeout: 20 }); await attachComposerImage(page, localImagePath); } const result = await submitQuote(page, kwargs.text, target.id); if (result.ok) { // Wait for network submission to complete await page.wait(3); } return [{ status: result.ok ? 'success' : 'failed', message: result.message, text: kwargs.text, ...(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__ = { buildQuoteComposerUrl, };