x.js
1 #!/usr/bin/env node 2 3 /** 4 * X (Twitter) Outreach Module 5 * Automates X DMs using Playwright with headed browser 6 */ 7 8 import { join, dirname } from 'path'; 9 import { fileURLToPath } from 'url'; 10 import Logger from '../utils/logger.js'; 11 import { 12 launchStealthBrowser, 13 createPersistentContext, 14 saveProfile, 15 restoreStorage, 16 getNextProfile, 17 } from '../utils/stealth-browser.js'; 18 import { 19 showFloatingMessage, 20 hideFloatingMessage, 21 waitForUser, 22 } from '../utils/browser-notifications.js'; 23 import { isOutreachRetriable, computeRetryAt } from '../utils/error-categories.js'; 24 import { getContactsDataWithFallback, setContactsJson } from '../utils/contacts-storage.js'; 25 import { run, getOne, getAll } from '../utils/db.js'; 26 import '../utils/load-env.js'; 27 28 const __filename = fileURLToPath(import.meta.url); 29 const __dirname = dirname(__filename); 30 const projectRoot = join(__dirname, '../..'); 31 32 const logger = new Logger('XOutreach'); 33 34 // XPath selectors from environment variables 35 const X_LOGIN_BUTTON_XPATH = 36 process.env.X_LOGIN_BUTTON_XPATH || 37 '//div[contains(@style, "position: absolute; bottom: 0px; width: 100%")]//span[text()="Log in"]'; 38 const X_PROFILE_LINK_XPATH = process.env.X_PROFILE_LINK_XPATH || '//a[@aria-label="Profile"]'; 39 const X_USERNAME_DIV_XPATH = process.env.X_USERNAME_DIV_XPATH || '//div[@data-testid="UserName"]'; 40 const X_MESSAGE_BUTTON_XPATH = 41 process.env.X_MESSAGE_BUTTON_XPATH || '//button[@aria-label="Message"]'; 42 43 /** 44 * Check if X login is required 45 * The sticky absolute banner containing the login button indicates login is needed 46 * When logged in, this banner disappears 47 */ 48 async function isLoginRequired(page) { 49 try { 50 const loginButton = page.locator(`xpath=${X_LOGIN_BUTTON_XPATH}`).first(); 51 return await loginButton.isVisible({ timeout: 2000 }); 52 } catch { 53 // If we can't find the login button, assume we're logged in 54 return false; 55 } 56 } 57 58 /** 59 * Extract our X username 60 */ 61 async function getOurXUsername(page) { 62 try { 63 const profileLink = page.locator(`xpath=${X_PROFILE_LINK_XPATH}`).first(); 64 const href = await profileLink.getAttribute('href'); 65 66 if (href && href.startsWith('/')) { 67 // Strip the leading slash to get username 68 return href.substring(1); 69 } 70 71 logger.warn('Could not find profile link or extract username'); 72 return null; 73 } catch (error) { 74 logger.error('Error extracting X username', { error: error.message }); 75 return null; 76 } 77 } 78 79 /** 80 * Get contact's name from their X profile 81 */ 82 async function getContactName(page) { 83 try { 84 const userNameDiv = page.locator(`xpath=${X_USERNAME_DIV_XPATH}`).first(); 85 const name = await userNameDiv.textContent(); 86 return name?.trim() || null; 87 } catch (error) { 88 logger.error('Error extracting contact name', { error: error.message }); 89 return null; 90 } 91 } 92 93 /** 94 * Update contact name in sites.contacts_json 95 */ 96 function updateContactName(siteId, contactUri, name) { 97 try { 98 const contacts = getContactsDataWithFallback(siteId, null); 99 100 if (!contacts) { 101 logger.warn('No contacts_json found for site', { siteId }); 102 return; 103 } 104 105 // Find the X contact and update the label 106 if (contacts.social_profiles) { 107 const xContact = contacts.social_profiles.find(p => p.uri === contactUri); 108 if (xContact && name) { 109 // Prepend name with comma and space 110 if (!xContact.label.includes(',')) { 111 xContact.label = `${xContact.label}, ${name}`; 112 } 113 } 114 } 115 116 setContactsJson(siteId, JSON.stringify(contacts)); 117 118 logger.info('Updated contact name in contacts_json', { siteId, name }); 119 } catch (error) { 120 logger.error('Error updating contact name', { error: error.message }); 121 } 122 } 123 124 /** 125 * Inline equivalent of markOutreachResult() for use while error-categories.js is still 126 * being migrated to PostgreSQL. Mirrors the same retry/terminal logic. 127 * @param {number} messageId 128 * @param {string} errorMessage 129 */ 130 async function markOutreachResultAsync(messageId, errorMessage) { 131 if (isOutreachRetriable(errorMessage)) { 132 const retryAt = computeRetryAt(errorMessage); 133 await run( 134 `UPDATE messages 135 SET delivery_status = 'retry_later', error_message = $1, retry_at = $2 136 WHERE id = $3`, 137 [errorMessage || 'Unknown error', retryAt, messageId] 138 ); 139 } else { 140 await run( 141 `UPDATE messages 142 SET delivery_status = 'failed', error_message = $1 143 WHERE id = $2`, 144 [errorMessage || 'Unknown error', messageId] 145 ); 146 } 147 } 148 149 /** 150 * Send X (Twitter) DM 151 */ 152 export async function sendX(outreachId) { 153 let browser; 154 let page; 155 156 try { 157 // Get outreach data 158 const outreach = await getOne( 159 `SELECT o.*, o.message_body AS proposal_text, s.domain 160 FROM messages o 161 JOIN sites s ON o.site_id = s.id 162 WHERE o.id = $1 AND o.direction = 'outbound'`, 163 [outreachId] 164 ); 165 166 if (!outreach) { 167 throw new Error(`Outreach #${outreachId} not found`); 168 } 169 170 if (outreach.contact_method !== 'x') { 171 throw new Error(`Outreach #${outreachId} is for ${outreach.contact_method}, not X`); 172 } 173 174 logger.info('Starting X outreach', { 175 outreachId, 176 domain: outreach.domain, 177 uri: outreach.contact_uri, 178 }); 179 180 // Get next profile using LRU rotation 181 const profileName = getNextProfile('x'); 182 logger.info('Using X profile', { profileName }); 183 184 // Launch with stealth plugin (headed for social media) 185 browser = await launchStealthBrowser({ 186 headless: false, 187 stealthLevel: 'aggressive', 188 }); 189 190 // Create persistent context (loads saved cookies) 191 const { page: newPage, profileLoaded } = await createPersistentContext( 192 browser, 193 'x', 194 profileName, 195 { viewport: null } 196 ); 197 page = newPage; 198 199 if (profileLoaded) { 200 logger.info('Profile loaded - checking login status', { profileName }); 201 } else { 202 logger.info('New profile - manual login will be required', { profileName }); 203 } 204 205 // Navigate to X profile 206 await page.goto(outreach.contact_uri, { waitUntil: 'networkidle' }); 207 208 // Restore localStorage/sessionStorage after navigation 209 await restoreStorage(page, 'x', profileName); 210 211 // Check if login is required (sticky banner with login button is visible) 212 if (await isLoginRequired(page)) { 213 logger.info('X login required (login banner visible), waiting for user...'); 214 215 // Click the login button 216 const loginButton = page.locator(`xpath=${X_LOGIN_BUTTON_XPATH}`).first(); 217 await loginButton.click(); 218 219 await waitForUser( 220 page, 221 'X login (banner will disappear after login)', 222 async () => !(await isLoginRequired(page)), 223 2000 224 ); 225 226 logger.info('Login complete - banner disappeared'); 227 228 // Save profile after successful login 229 await saveProfile(page, 'x', profileName); 230 logger.info('Saved X profile after login', { profileName }); 231 232 // Navigate to profile after login 233 await page.goto(outreach.contact_uri, { waitUntil: 'networkidle' }); 234 } 235 236 // Get our username 237 const ourUsername = await getOurXUsername(page); 238 if (ourUsername) { 239 logger.info('Detected X account', { username: ourUsername }); 240 241 // Update outreach record with our account 242 await run('UPDATE messages SET our_account = $1 WHERE id = $2', [ourUsername, outreachId]); 243 244 // Save username to profile metadata 245 await saveProfile(page, 'x', profileName, { username: ourUsername }); 246 } 247 248 // Get contact's name 249 const contactName = await getContactName(page); 250 if (contactName) { 251 logger.info('Contact name', { name: contactName }); 252 updateContactName(outreach.site_id, outreach.contact_uri, contactName); 253 } 254 255 // Check for Message button 256 const messageButton = page.locator(`xpath=${X_MESSAGE_BUTTON_XPATH}`).first(); 257 const hasMessageButton = await messageButton.isVisible({ timeout: 2000 }).catch(() => false); 258 259 if (!hasMessageButton) { 260 logger.warn('No Message button found - user cannot receive DMs'); 261 262 // Update status to no_message_button 263 await run( 264 'UPDATE messages SET delivery_status = $1, error_message = $2 WHERE id = $3', 265 ['failed', 'No DM button', outreachId] 266 ); 267 268 await showFloatingMessage(page, '⚠️ This user cannot receive DMs (no Message button)', { 269 backgroundColor: '#DC2626', 270 }); 271 272 await page.waitForTimeout(3000); 273 await hideFloatingMessage(page); 274 275 return; 276 } 277 278 // Click Message button 279 logger.info('Found Message button, clicking...'); 280 await messageButton.click(); 281 282 // Wait for message compose area to appear 283 await page.waitForTimeout(2000); 284 285 // Note: The exact selector for the message input will need to be discovered 286 // For now, we'll try common selectors 287 const messageInputSelectors = [ 288 '[data-testid="dmComposerTextInput"]', 289 '[contenteditable="true"]', 290 'textarea', 291 '[role="textbox"]', 292 ]; 293 294 let messageInput = null; 295 for (const selector of messageInputSelectors) { 296 try { 297 const input = page.locator(selector).last(); 298 if (await input.isVisible({ timeout: 1000 })) { 299 messageInput = input; 300 break; 301 } 302 } catch { 303 continue; 304 } 305 } 306 307 if (messageInput) { 308 // Paste the proposal text 309 await messageInput.click(); 310 await messageInput.fill(outreach.proposal_text); 311 312 logger.info('Proposal text pasted, waiting for user review...'); 313 } else { 314 logger.warn('Could not find message input field - showing proposal in notification'); 315 316 await showFloatingMessage( 317 page, 318 'Could not auto-fill message. Please paste manually from clipboard.', 319 { backgroundColor: '#DC2626' } 320 ); 321 322 // Copy to clipboard 323 /* eslint-disable no-undef */ 324 await page.evaluate(text => { 325 navigator.clipboard.writeText(text); 326 }, outreach.proposal_text); 327 /* eslint-enable no-undef */ 328 } 329 330 // Show floating message for user review 331 await showFloatingMessage(page, '⏳ Review the message and click Send when ready', { 332 backgroundColor: '#1DA1F2', // X blue 333 }); 334 335 // Wait for user to send 336 logger.info('Waiting for user to send message...'); 337 338 // Wait up to 5 minutes for user to send 339 await page.waitForTimeout(300000); 340 341 await hideFloatingMessage(page); 342 343 // Update status to sent 344 await run( 345 'UPDATE messages SET delivery_status = $1, delivered_at = CURRENT_TIMESTAMP WHERE id = $2', 346 ['sent', outreachId] 347 ); 348 349 logger.success('X outreach completed', { outreachId }); 350 } catch (error) { 351 logger.error('X outreach failed', { 352 outreachId, 353 error: error.message, 354 }); 355 356 // retry_later for transient errors, failed for terminal 357 await markOutreachResultAsync(outreachId, error.message); 358 359 throw error; 360 } finally { 361 if (page) await page.close().catch(() => {}); 362 if (browser) await browser.close().catch(() => {}); 363 } 364 } 365 366 /** 367 * Bulk X outreach 368 */ 369 export async function bulkX(limit = 10) { 370 const pending = await getAll( 371 `SELECT id FROM messages 372 WHERE contact_method = 'x' 373 AND direction = 'outbound' 374 AND approval_status = 'approved' 375 AND delivery_status IS NULL 376 LIMIT $1`, 377 [limit] 378 ); 379 380 logger.info('Starting bulk X outreach', { 381 count: pending.length, 382 limit, 383 }); 384 385 for (const outreach of pending) { 386 try { 387 await sendX(outreach.id); 388 } catch (error) { 389 logger.error('Failed to send X message', { 390 id: outreach.id, 391 error: error.message, 392 }); 393 // Continue with next outreach 394 } 395 } 396 397 logger.success('Bulk X outreach completed'); 398 } 399 400 // CLI support 401 if (import.meta.url === `file://${process.argv[1]}`) { 402 const command = process.argv[2]; 403 const arg = process.argv[3]; 404 405 if (command === 'send' && arg) { 406 sendX(parseInt(arg, 10)).catch(error => { 407 logger.error('X outreach failed', { error: error.message }); 408 process.exit(1); 409 }); 410 } else if (command === 'bulk') { 411 const limit = arg ? parseInt(arg, 10) : 10; 412 bulkX(limit).catch(error => { 413 logger.error('Bulk X outreach failed', { error: error.message }); 414 process.exit(1); 415 }); 416 } else { 417 console.log('Usage:'); 418 console.log(' node x.js send <outreach_id>'); 419 console.log(' node x.js bulk [limit]'); 420 process.exit(1); 421 } 422 }