post.js
1 import * as fs from 'node:fs'; 2 import * as path from 'node:path'; 3 import { cli, Strategy } from '@jackwener/opencli/registry'; 4 import { CommandExecutionError } from '@jackwener/opencli/errors'; 5 const MAX_IMAGES = 4; 6 const UPLOAD_POLL_MS = 500; 7 const UPLOAD_TIMEOUT_MS = 30_000; 8 const SUPPORTED_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp']); 9 function validateImagePaths(raw) { 10 const paths = raw.split(',').map(s => s.trim()).filter(Boolean); 11 if (paths.length > MAX_IMAGES) { 12 throw new CommandExecutionError(`Too many images: ${paths.length} (max ${MAX_IMAGES})`); 13 } 14 return paths.map(p => { 15 const absPath = path.resolve(p); 16 const ext = path.extname(absPath).toLowerCase(); 17 if (!SUPPORTED_EXTENSIONS.has(ext)) { 18 throw new CommandExecutionError(`Unsupported image format "${ext}". Supported: jpg, png, gif, webp`); 19 } 20 const stat = fs.statSync(absPath, { throwIfNoEntry: false }); 21 if (!stat || !stat.isFile()) { 22 throw new CommandExecutionError(`Not a valid file: ${absPath}`); 23 } 24 return absPath; 25 }); 26 } 27 cli({ 28 site: 'twitter', 29 name: 'post', 30 description: 'Post a new tweet/thread', 31 domain: 'x.com', 32 strategy: Strategy.UI, 33 browser: true, 34 args: [ 35 { name: 'text', type: 'string', required: true, positional: true, help: 'The text content of the tweet' }, 36 { name: 'images', type: 'string', required: false, help: 'Image paths, comma-separated, max 4 (jpg/png/gif/webp)' }, 37 ], 38 columns: ['status', 'message', 'text'], 39 func: async (page, kwargs) => { 40 if (!page) 41 throw new CommandExecutionError('Browser session required for twitter post'); 42 // Validate images upfront before any browser interaction 43 const absPaths = kwargs.images ? validateImagePaths(String(kwargs.images)) : []; 44 // 1. Navigate to compose modal 45 await page.goto('https://x.com/compose/tweet'); 46 await page.wait(3); 47 // 2. Type the text via clipboard paste (handles newlines in Draft.js) 48 const typeResult = await page.evaluate(`(async () => { 49 try { 50 const box = document.querySelector('[data-testid="tweetTextarea_0"]'); 51 if (!box) return { ok: false, message: 'Could not find the tweet composer text area.' }; 52 box.focus(); 53 const dt = new DataTransfer(); 54 dt.setData('text/plain', ${JSON.stringify(kwargs.text)}); 55 box.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })); 56 return { ok: true }; 57 } catch (e) { return { ok: false, message: String(e) }; } 58 })()`); 59 if (!typeResult.ok) { 60 return [{ status: 'failed', message: typeResult.message, text: kwargs.text }]; 61 } 62 // 3. Attach images if provided 63 if (absPaths.length > 0) { 64 if (!page.setFileInput) { 65 throw new CommandExecutionError('Browser extension does not support file upload. Please update the extension.'); 66 } 67 await page.setFileInput(absPaths, 'input[data-testid="fileInput"]'); 68 // Poll until attachments render and tweet button is enabled 69 const pollIterations = Math.ceil(UPLOAD_TIMEOUT_MS / UPLOAD_POLL_MS); 70 const uploaded = await page.evaluate(`(async () => { 71 for (let i = 0; i < ${JSON.stringify(pollIterations)}; i++) { 72 await new Promise(r => setTimeout(r, ${JSON.stringify(UPLOAD_POLL_MS)})); 73 const container = document.querySelector('[data-testid="attachments"]'); 74 if (!container) continue; 75 if (container.querySelectorAll('[role="group"]').length !== ${JSON.stringify(absPaths.length)}) continue; 76 const btn = document.querySelector('[data-testid="tweetButton"]') || document.querySelector('[data-testid="tweetButtonInline"]'); 77 if (btn && !btn.disabled) return true; 78 } 79 return false; 80 })()`); 81 if (!uploaded) { 82 return [{ status: 'failed', message: `Image upload timed out (${UPLOAD_TIMEOUT_MS / 1000}s).`, text: kwargs.text }]; 83 } 84 } 85 // 4. Click the post button 86 await page.wait(1); 87 const result = await page.evaluate(`(async () => { 88 try { 89 const btn = document.querySelector('[data-testid="tweetButton"]') || document.querySelector('[data-testid="tweetButtonInline"]'); 90 if (btn && !btn.disabled) { btn.click(); return { ok: true, message: 'Tweet posted successfully.' }; } 91 return { ok: false, message: 'Tweet button is disabled or not found.' }; 92 } catch (e) { return { ok: false, message: String(e) }; } 93 })()`); 94 if (result.ok) 95 await page.wait(3); 96 return [{ status: result.ok ? 'success' : 'failed', message: result.message, text: kwargs.text }]; 97 } 98 });