Files
jackwener__opencli/clis/twitter/reply.ts
jakevin 80eef46b4e refactor: monorepo adapter separation (clis/ at root) (#782)
* refactor: move adapters from src/clis/ to root clis/ for monorepo separation

Separates CLI adapters from the core runtime to prepare for independent
adapter distribution via postinstall fetch.

Key changes:
- Move src/clis/ → clis/ (adapters at repo root)
- Change tsconfig rootDir from "src" to "." so tsc compiles both
- Create root-level shim files (registry.ts, errors.ts, etc.) so adapter
  relative imports (../../registry.js) resolve correctly
- Update build-manifest.ts, main.ts paths for new dist/src/ structure
- Expand ensureUserCliCompatShims() to cover all adapter import targets
  (types, utils, logger, launcher, browser/*, download/*, pipeline/*)
- Add scripts/fetch-adapters.js postinstall for ~/.opencli/clis/ sync
- Update vitest.config.ts adapter test paths
- Add package.json files field to exclude adapters from npm package

Official adapter files are unconditionally overwritten on update;
user-created files not in the manifest are preserved.

* fix: add dist/clis/ and cli-manifest.json to npm files, harden fetch-adapters

- Add dist/clis/ and dist/cli-manifest.json to package.json files field
  so built-in adapters and manifest ship with the npm package
- Replace execSync with execFileSync to prevent command injection
- Add version check to skip redundant adapter fetches
- Track tmpRoot explicitly for reliable cleanup

* fix: address review blockers — manifest-based updates, global-only fetch, first-run fallback

1. Manifest-based update strategy:
   - Read old manifest to identify previously-official files
   - Clean up files removed upstream (in old manifest but not new)
   - User-created files (never in any manifest) remain untouched

2. Only run fetch-adapters on global install (npm_config_global=true)
   or explicit OPENCLI_FETCH=1, preventing heavy side effects for
   local/dev installs

3. First-run fallback in discovery.ts:
   - ensureUserAdapters() checks for adapter-manifest.json
   - If missing and ~/.opencli/clis/ is empty, spawns fetch-adapters.js
   - Guarantees adapters are available even with --ignore-scripts

* fix: remove OPENCLI_FETCH env var, use internal _OPENCLI_FIRST_RUN instead

* feat: also support OPENCLI_FETCH=1 for explicit adapter fetch trigger

* simplify: replace git clone with local copy from dist/clis/

Adapters already ship in the npm package (dist/clis/), so there's no
need to clone from GitHub. Copy directly from the installed package:

- Eliminates git, curl, tar dependencies
- No network calls in postinstall
- No timeout/offline issues
- Version always matches the installed CLI
- ~65 lines of clone/download code replaced by one cpSync loop
2026-04-05 01:46:36 +08:00

310 lines
11 KiB
TypeScript

import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { CommandExecutionError } from '../../errors.js';
import { cli, Strategy } from '../../registry.js';
import type { IPage } from '../../types.js';
const REPLY_FILE_INPUT_SELECTOR = 'input[type="file"][data-testid="fileInput"]';
const SUPPORTED_IMAGE_EXTENSIONS = new Set([
'.jpg',
'.jpeg',
'.png',
'.gif',
'.webp',
]);
const MAX_IMAGE_SIZE_BYTES = 20 * 1024 * 1024; // 20 MB (Twitter allows 5MB images, 15MB GIFs)
const CONTENT_TYPE_TO_EXTENSION: Record<string, string> = {
'image/jpeg': '.jpg',
'image/jpg': '.jpg',
'image/png': '.png',
'image/gif': '.gif',
'image/webp': '.webp',
};
function resolveImagePath(imagePath: string): string {
const absPath = path.resolve(imagePath);
if (!fs.existsSync(absPath)) {
throw new Error(`Image file not found: ${absPath}`);
}
const ext = path.extname(absPath).toLowerCase();
if (!SUPPORTED_IMAGE_EXTENSIONS.has(ext)) {
throw new Error(`Unsupported image format "${ext}". Supported: jpg, jpeg, png, gif, webp`);
}
const stat = fs.statSync(absPath);
if (stat.size > MAX_IMAGE_SIZE_BYTES) {
throw new Error(
`Image too large: ${(stat.size / 1024 / 1024).toFixed(1)} MB (max ${MAX_IMAGE_SIZE_BYTES / 1024 / 1024} MB)`
);
}
return absPath;
}
function extractTweetId(url: string): string {
let pathname = '';
try {
pathname = new URL(url).pathname;
} catch {
throw new Error(`Invalid tweet URL: ${url}`);
}
const match = pathname.match(/\/status\/(\d+)/);
if (!match?.[1]) {
throw new Error(`Could not extract tweet ID from URL: ${url}`);
}
return match[1];
}
function buildReplyComposerUrl(url: string): string {
return `https://x.com/compose/post?in_reply_to=${extractTweetId(url)}`;
}
function resolveImageExtension(url: string, contentType: string | null): string {
const normalizedContentType = (contentType || '').split(';')[0].trim().toLowerCase();
if (normalizedContentType && CONTENT_TYPE_TO_EXTENSION[normalizedContentType]) {
return CONTENT_TYPE_TO_EXTENSION[normalizedContentType];
}
try {
const pathname = new URL(url).pathname;
const ext = path.extname(pathname).toLowerCase();
if (SUPPORTED_IMAGE_EXTENSIONS.has(ext)) return ext;
} catch {
// Fall through to the final error below.
}
throw new Error(
`Unsupported remote image format "${normalizedContentType || 'unknown'}". ` +
'Supported: jpg, jpeg, png, gif, webp'
);
}
async function downloadRemoteImage(imageUrl: string): Promise<string> {
let parsed: URL;
try {
parsed = new URL(imageUrl);
} catch {
throw new Error(`Invalid image URL: ${imageUrl}`);
}
if (!/^https?:$/.test(parsed.protocol)) {
throw new Error(`Unsupported image URL protocol: ${parsed.protocol}`);
}
const response = await fetch(imageUrl);
if (!response.ok) {
throw new Error(`Image download failed: HTTP ${response.status}`);
}
const contentLength = Number(response.headers.get('content-length') || '0');
if (contentLength > MAX_IMAGE_SIZE_BYTES) {
throw new Error(`Image too large: ${(contentLength / 1024 / 1024).toFixed(1)} MB (max ${MAX_IMAGE_SIZE_BYTES / 1024 / 1024} MB)`);
}
const ext = resolveImageExtension(imageUrl, response.headers.get('content-type'));
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-twitter-reply-'));
const tmpPath = path.join(tmpDir, `image${ext}`);
const buffer = Buffer.from(await response.arrayBuffer());
if (buffer.byteLength > MAX_IMAGE_SIZE_BYTES) {
fs.rmSync(tmpDir, { recursive: true, force: true });
throw new Error(`Image too large: ${(buffer.byteLength / 1024 / 1024).toFixed(1)} MB (max ${MAX_IMAGE_SIZE_BYTES / 1024 / 1024} MB)`);
}
fs.writeFileSync(tmpPath, buffer);
return tmpPath;
}
async function attachReplyImage(page: IPage, absImagePath: string): Promise<void> {
let uploaded = false;
if (page.setFileInput) {
try {
await page.setFileInput([absImagePath], REPLY_FILE_INPUT_SELECTOR);
uploaded = true;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (!msg.includes('Unknown action') && !msg.includes('not supported')) {
throw new Error(`Image upload failed: ${msg}`);
}
// setFileInput not supported by extension — fall through to base64 fallback
}
}
if (!uploaded) {
const ext = path.extname(absImagePath).toLowerCase();
const mimeType = ext === '.png'
? 'image/png'
: ext === '.gif'
? 'image/gif'
: ext === '.webp'
? 'image/webp'
: 'image/jpeg';
const base64 = fs.readFileSync(absImagePath).toString('base64');
if (base64.length > 500_000) {
console.warn(
`[warn] Image base64 payload is ${(base64.length / 1024 / 1024).toFixed(1)}MB. ` +
'This may fail with the browser bridge. Update the extension to v1.6+ for CDP-based upload, ' +
'or compress the image before attaching.'
);
}
const upload = await page.evaluate(`
(() => {
const input = document.querySelector(${JSON.stringify(REPLY_FILE_INPUT_SELECTOR)});
if (!input) return { ok: false, error: 'No file input found on page' };
const binary = atob(${JSON.stringify(base64)});
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
const dt = new DataTransfer();
const blob = new Blob([bytes], { type: ${JSON.stringify(mimeType)} });
dt.items.add(new File([blob], ${JSON.stringify(path.basename(absImagePath))}, { type: ${JSON.stringify(mimeType)} }));
Object.defineProperty(input, 'files', { value: dt.files, writable: false });
input.dispatchEvent(new Event('change', { bubbles: true }));
input.dispatchEvent(new Event('input', { bubbles: true }));
return { ok: true };
})()
`);
if (!upload?.ok) {
throw new Error(`Image upload failed: ${upload?.error ?? 'unknown error'}`);
}
}
await page.wait(2);
const uploadState = await page.evaluate(`
(() => {
const previewCount = document.querySelectorAll(
'[data-testid="attachments"] img, [data-testid="attachments"] video, [data-testid="tweetPhoto"]'
).length;
const hasMedia = previewCount > 0
|| !!document.querySelector('[data-testid="attachments"]')
|| !!Array.from(document.querySelectorAll('button,[role="button"]')).find((el) =>
/remove media|remove image|remove/i.test((el.getAttribute('aria-label') || '') + ' ' + (el.textContent || ''))
);
return { ok: hasMedia, previewCount };
})()
`);
if (!uploadState?.ok) {
throw new Error('Image upload failed: preview did not appear.');
}
}
async function submitReply(page: IPage, text: string): Promise<{ ok: boolean; message: string }> {
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 buttons = Array.from(
document.querySelectorAll('[data-testid="tweetButton"], [data-testid="tweetButtonInline"]')
);
const btn = buttons.find((el) => visible(el) && !el.disabled);
if (!btn) {
return { ok: false, message: 'Reply button is disabled or not found.' };
}
btn.click();
return { ok: true, message: 'Reply posted successfully.' };
} catch (e) {
return { ok: false, message: e.toString() };
}
})()`);
}
cli({
site: 'twitter',
name: 'reply',
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'],
func: async (page: IPage | null, kwargs: any) => {
if (!page) throw new CommandExecutionError('Browser session required for twitter reply');
if (kwargs.image && kwargs['image-url']) {
throw new Error('Use either --image or --image-url, not both.');
}
let localImagePath: string | undefined;
let cleanupDir: string | undefined;
try {
if (kwargs.image) {
localImagePath = resolveImagePath(kwargs.image);
} else if (kwargs['image-url']) {
localImagePath = await downloadRemoteImage(kwargs['image-url']);
cleanupDir = path.dirname(localImagePath);
}
// Dedicated composer is more reliable for image replies because the media
// toolbar and file input are consistently present there.
if (localImagePath) {
await page.goto(buildReplyComposerUrl(kwargs.url));
await page.wait({ selector: '[data-testid="tweetTextarea_0"]' });
await page.wait({ selector: REPLY_FILE_INPUT_SELECTOR, timeout: 20 });
await attachReplyImage(page, localImagePath);
} else {
await page.goto(kwargs.url);
await page.wait({ selector: '[data-testid="primaryColumn"]' });
}
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,
...(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,
downloadRemoteImage,
extractTweetId,
resolveImageExtension,
resolveImagePath,
};