/ clis / twitter / post.js
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  });