← back to Goodquestion Ai

scripts/social-post.cjs

168 lines

#!/usr/bin/env node
/**
 * Unified Social Poster — posts to Bluesky + X/Twitter simultaneously
 * Usage:
 *   node social-post.cjs --text "Post text" [--url "https://..."] [--link-text "Read more"]
 *   node social-post.cjs --blog "Post Title" --slug "post-slug"
 *
 * Handles rate limits: saves failed tweets to /tmp/x-pending-tweet.json for cron retry.
 */
require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });

const { BskyAgent, RichText } = require('@atproto/api');
const { TwitterApi } = require('twitter-api-v2');
const fs = require('fs');

const BSKY_HANDLE = 'agentabrams.bsky.social';
const BSKY_PASSWORD = process.env.BSKY_PASSWORD;
const BASE_URL = 'https://goodquestion.ai';

function parseArgs() {
  const args = process.argv.slice(2);
  const r = {};
  for (let i = 0; i < args.length; i++) {
    if (args[i] === '--text' && args[i + 1]) r.text = args[++i];
    if (args[i] === '--url' && args[i + 1]) r.url = args[++i];
    if (args[i] === '--link-text' && args[i + 1]) r.linkText = args[++i];
    if (args[i] === '--blog' && args[i + 1]) r.blogTitle = args[++i];
    if (args[i] === '--slug' && args[i + 1]) r.slug = args[++i];
    if (args[i] === '--tags' && args[i + 1]) r.tags = args[++i];
    if (args[i] === '--bsky-only') r.bskyOnly = true;
    if (args[i] === '--x-only') r.xOnly = true;
    if (args[i] === '--dry-run') r.dryRun = true;
  }
  return r;
}

async function postBluesky(text, url, linkText) {
  const agent = new BskyAgent({ service: 'https://bsky.social' });
  await agent.login({ identifier: BSKY_HANDLE, password: BSKY_PASSWORD });

  let fullText = text;
  if (url && !fullText.includes(url)) fullText += '\n\n' + url;
  if (fullText.length > 300) fullText = fullText.substring(0, 297) + '...';

  const rt = new RichText({ text: fullText });
  await rt.detectFacets(agent);

  let embed = undefined;
  if (url) {
    embed = {
      $type: 'app.bsky.embed.external',
      external: {
        uri: url,
        title: linkText || text.substring(0, 60),
        description: 'Read more at goodquestion.ai',
      },
    };
  }

  const result = await agent.post({
    text: rt.text,
    facets: rt.facets,
    embed,
    createdAt: new Date().toISOString(),
  });

  const postId = result.uri.split('/').pop();
  return {
    success: true,
    uri: result.uri,
    cid: result.cid,
    url: `https://bsky.app/profile/${BSKY_HANDLE}/post/${postId}`,
  };
}

async function postTwitter(text) {
  const client = new TwitterApi({
    appKey: process.env.TWITTER_API_KEY,
    appSecret: process.env.TWITTER_API_SECRET,
    accessToken: process.env.TWITTER_ACCESS_TOKEN,
    accessSecret: process.env.TWITTER_ACCESS_TOKEN_SECRET,
  });

  let tweetText = text;
  if (tweetText.length > 280) tweetText = tweetText.substring(0, 277) + '...';

  const result = await client.v2.tweet(tweetText);
  return {
    success: true,
    id: result.data.id,
    url: `https://x.com/agentabrams/status/${result.data.id}`,
  };
}

function savePendingTweet(text) {
  const pending = {
    text,
    retries: 0,
    created: new Date().toISOString(),
  };
  fs.writeFileSync('/tmp/x-pending-tweet.json', JSON.stringify(pending, null, 2));
  console.log('[X] Saved to /tmp/x-pending-tweet.json for cron retry');
}

async function main() {
  const opts = parseArgs();

  // Build post text from either --text or --blog mode
  let text, url;
  if (opts.blogTitle && opts.slug) {
    url = `${BASE_URL}/posts/${opts.slug}/`;
    const tags = opts.tags ? opts.tags.split(',').map(t => `#${t.trim()}`).join(' ') : '#ClaudeCode #BuildInPublic';
    text = `${opts.blogTitle}\n\n${url}\n\n${tags}`;
  } else if (opts.text) {
    text = opts.text;
    url = opts.url;
  } else {
    console.error('Usage:');
    console.error('  node social-post.cjs --text "Post text" [--url "https://..."]');
    console.error('  node social-post.cjs --blog "Title" --slug "post-slug" [--tags "ai,claude"]');
    process.exit(1);
  }

  if (opts.dryRun) {
    console.log('[DRY RUN]');
    console.log('Text:', text);
    console.log('URL:', url || '(none)');
    return;
  }

  const results = { bluesky: null, twitter: null };

  // Post to Bluesky
  if (!opts.xOnly) {
    try {
      results.bluesky = await postBluesky(text, url, opts.linkText);
      console.log('[Bluesky] Posted:', results.bluesky.url);
    } catch (e) {
      console.error('[Bluesky] Failed:', e.message);
      results.bluesky = { success: false, error: e.message };
    }
  }

  // Post to X/Twitter
  if (!opts.bskyOnly) {
    try {
      results.twitter = await postTwitter(text);
      console.log('[X] Posted:', results.twitter.url);
    } catch (e) {
      if (e.message && e.message.includes('429')) {
        console.warn('[X] Rate limited (429) — saving for retry');
        savePendingTweet(text);
        results.twitter = { success: false, error: 'rate_limited', saved: true };
      } else {
        console.error('[X] Failed:', e.message);
        results.twitter = { success: false, error: e.message };
      }
    }
  }

  // Summary
  console.log('\n--- Results ---');
  if (results.bluesky) console.log('Bluesky:', results.bluesky.success ? results.bluesky.url : `FAILED (${results.bluesky.error})`);
  if (results.twitter) console.log('X/Twitter:', results.twitter.success ? results.twitter.url : `FAILED (${results.twitter.error})`);
}

main().catch(e => { console.error('Fatal:', e.message); process.exit(1); });