/ src / outreach / x.js
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  }