/** * X Integration - Shared utilities * Used by all X scripts */ import { chromium, BrowserContext, Page } from 'playwright'; import fs from 'fs'; import path from 'path'; import { config } from './config.js'; export { config }; export interface ScriptResult { success: boolean; message: string; data?: unknown; } /** * Read input from stdin */ export async function readInput(): Promise { return new Promise((resolve, reject) => { let data = ''; process.stdin.setEncoding('utf8'); process.stdin.on('data', chunk => { data += chunk; }); process.stdin.on('end', () => { try { resolve(JSON.parse(data)); } catch (err) { reject(new Error(`Invalid JSON input: ${err}`)); } }); process.stdin.on('error', reject); }); } /** * Write result to stdout */ export function writeResult(result: ScriptResult): void { console.log(JSON.stringify(result)); } /** * Clean up browser lock files */ export function cleanupLockFiles(): void { for (const lockFile of ['SingletonLock', 'SingletonSocket', 'SingletonCookie']) { const lockPath = path.join(config.browserDataDir, lockFile); if (fs.existsSync(lockPath)) { try { fs.unlinkSync(lockPath); } catch {} } } } /** * Validate tweet/reply content */ export function validateContent(content: string | undefined, type = 'Tweet'): ScriptResult | null { if (!content || content.length === 0) { return { success: false, message: `${type} content cannot be empty` }; } if (content.length > config.limits.tweetMaxLength) { return { success: false, message: `${type} exceeds ${config.limits.tweetMaxLength} character limit (current: ${content.length})` }; } return null; // Valid } /** * Get browser context with persistent profile */ export async function getBrowserContext(): Promise { if (!fs.existsSync(config.authPath)) { throw new Error('X authentication not configured. Run /x-integration to complete login.'); } cleanupLockFiles(); const context = await chromium.launchPersistentContext(config.browserDataDir, { executablePath: config.chromePath, headless: false, viewport: config.viewport, args: config.chromeArgs, ignoreDefaultArgs: config.chromeIgnoreDefaultArgs, }); return context; } /** * Extract tweet ID from URL or raw ID */ export function extractTweetId(input: string): string | null { const urlMatch = input.match(/(?:x\.com|twitter\.com)\/\w+\/status\/(\d+)/); if (urlMatch) return urlMatch[1]; if (/^\d+$/.test(input.trim())) return input.trim(); return null; } /** * Navigate to a tweet page */ export async function navigateToTweet( context: BrowserContext, tweetUrl: string ): Promise<{ page: Page; success: boolean; error?: string }> { const page = context.pages()[0] || await context.newPage(); let url = tweetUrl; const tweetId = extractTweetId(tweetUrl); if (tweetId && !tweetUrl.startsWith('http')) { url = `https://x.com/i/status/${tweetId}`; } try { await page.goto(url, { timeout: config.timeouts.navigation, waitUntil: 'domcontentloaded' }); await page.waitForTimeout(config.timeouts.pageLoad); const exists = await page.locator('article[data-testid="tweet"]').first().isVisible().catch(() => false); if (!exists) { return { page, success: false, error: 'Tweet not found. It may have been deleted or the URL is invalid.' }; } return { page, success: true }; } catch (err) { return { page, success: false, error: `Navigation failed: ${err instanceof Error ? err.message : String(err)}` }; } } /** * Run script with error handling */ export async function runScript( handler: (input: T) => Promise ): Promise { try { const input = await readInput(); const result = await handler(input); writeResult(result); } catch (err) { writeResult({ success: false, message: `Script execution failed: ${err instanceof Error ? err.message : String(err)}` }); process.exit(1); } }