← back to Goodquestion Ai

scripts/social-monitor.cjs

305 lines

#!/usr/bin/env node
/**
 * Social Monitor — checks for new mentions/replies on Bluesky + X/Twitter
 * Usage:
 *   node social-monitor.cjs                    # Check both platforms
 *   node social-monitor.cjs --platform bsky    # Bluesky only
 *   node social-monitor.cjs --platform x       # X only
 *   node social-monitor.cjs --slack             # Send summary to Slack
 *   node social-monitor.cjs --since 2h          # Only last 2 hours
 *
 * Stores last-check timestamps in /tmp/social-monitor-state.json
 */
require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });

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

const STATE_FILE = '/tmp/social-monitor-state.json';
const BSKY_HANDLE = 'agentabrams.bsky.social';
const BSKY_PASSWORD = process.env.BSKY_PASSWORD;
const SLACK_WEBHOOK = process.env.SLACK_WEBHOOK_URL;

function parseArgs() {
  const args = process.argv.slice(2);
  const r = { platform: 'both', slack: false, since: null };
  for (let i = 0; i < args.length; i++) {
    if (args[i] === '--platform' && args[i + 1]) r.platform = args[++i];
    if (args[i] === '--slack') r.slack = true;
    if (args[i] === '--since' && args[i + 1]) r.since = args[++i];
  }
  return r;
}

function loadState() {
  try {
    return JSON.parse(fs.readFileSync(STATE_FILE, 'utf-8'));
  } catch {
    return { bsky: { lastCheck: null, lastSeenUri: null }, x: { lastCheck: null, lastSeenId: null } };
  }
}

function saveState(state) {
  fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
}

function parseSince(since) {
  if (!since) return null;
  const match = since.match(/^(\d+)(h|m|d)$/);
  if (!match) return null;
  const [, num, unit] = match;
  const ms = { h: 3600000, m: 60000, d: 86400000 }[unit];
  return new Date(Date.now() - parseInt(num) * ms);
}

async function checkBluesky(state, sinceDate) {
  const agent = new BskyAgent({ service: 'https://bsky.social' });
  await agent.login({ identifier: BSKY_HANDLE, password: BSKY_PASSWORD });

  const notifications = [];

  try {
    const res = await agent.listNotifications({ limit: 50 });
    const items = res.data.notifications || [];

    for (const n of items) {
      // Filter to replies, mentions, likes, reposts, follows
      const createdAt = new Date(n.indexedAt);

      // Skip if before sinceDate or last check
      const cutoff = sinceDate || (state.bsky.lastCheck ? new Date(state.bsky.lastCheck) : new Date(Date.now() - 86400000));
      if (createdAt < cutoff) continue;

      const entry = {
        platform: 'bluesky',
        type: n.reason,
        author: n.author.handle,
        displayName: n.author.displayName || n.author.handle,
        time: n.indexedAt,
        uri: n.uri,
        cid: n.cid,
      };

      // For replies, get the text
      if (n.reason === 'reply' && n.record && n.record.text) {
        entry.text = n.record.text;
        // Get the parent URI for threading
        if (n.record.reply && n.record.reply.parent) {
          entry.parentUri = n.record.reply.parent.uri;
        }
      }

      // For mentions
      if (n.reason === 'mention' && n.record && n.record.text) {
        entry.text = n.record.text;
      }

      // For likes/reposts, note what was liked
      if ((n.reason === 'like' || n.reason === 'repost') && n.reasonSubject) {
        entry.subjectUri = n.reasonSubject;
      }

      if (n.reason === 'follow') {
        entry.text = `New follower: ${entry.displayName}`;
      }

      notifications.push(entry);
    }
  } catch (e) {
    console.error('[Bluesky] Error fetching notifications:', e.message);
  }

  state.bsky.lastCheck = new Date().toISOString();
  if (notifications.length > 0) {
    state.bsky.lastSeenUri = notifications[0].uri;
  }

  return notifications;
}

async function checkTwitter(state, sinceDate) {
  const notifications = [];

  try {
    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,
    });

    // Get user ID first
    const me = await client.v2.me();
    const userId = me.data.id;

    // Get mentions timeline
    const params = {
      max_results: 20,
      'tweet.fields': 'created_at,author_id,in_reply_to_user_id,conversation_id',
      'user.fields': 'username,name',
      expansions: 'author_id',
    };

    if (state.x.lastSeenId) {
      params.since_id = state.x.lastSeenId;
    }

    const mentions = await client.v2.userMentionTimeline(userId, params);

    const users = {};
    if (mentions.includes && mentions.includes.users) {
      for (const u of mentions.includes.users) {
        users[u.id] = { username: u.username, name: u.name };
      }
    }

    if (mentions.data && mentions.data.data) {
      for (const tweet of mentions.data.data) {
        const createdAt = new Date(tweet.created_at);
        const cutoff = sinceDate || (state.x.lastCheck ? new Date(state.x.lastCheck) : new Date(Date.now() - 86400000));
        if (createdAt < cutoff) continue;

        const author = users[tweet.author_id] || { username: 'unknown', name: 'Unknown' };

        notifications.push({
          platform: 'x',
          type: tweet.in_reply_to_user_id ? 'reply' : 'mention',
          author: author.username,
          displayName: author.name,
          time: tweet.created_at,
          text: tweet.text,
          tweetId: tweet.id,
          conversationId: tweet.conversation_id,
        });
      }
    }

    state.x.lastCheck = new Date().toISOString();
    if (notifications.length > 0) {
      state.x.lastSeenId = notifications[0].tweetId;
    }
  } catch (e) {
    if (e.message && e.message.includes('429')) {
      console.warn('[X] Rate limited — skipping');
    } else {
      console.error('[X] Error fetching mentions:', e.message);
    }
  }

  return notifications;
}

function formatNotifications(items) {
  if (items.length === 0) return 'No new notifications.';

  const lines = [];
  const byType = {};
  for (const n of items) {
    const key = n.type;
    if (!byType[key]) byType[key] = [];
    byType[key].push(n);
  }

  // Replies first (most important for engagement)
  if (byType.reply) {
    lines.push(`\n== REPLIES (${byType.reply.length}) ==`);
    for (const r of byType.reply) {
      const platform = r.platform === 'bluesky' ? '[BSky]' : '[X]';
      lines.push(`${platform} @${r.author}: "${r.text}"`);
      if (r.uri) lines.push(`  -> Reply: node social-reply.cjs --platform bsky --uri "${r.uri}" --text "your reply"`);
      if (r.tweetId) lines.push(`  -> Reply: node social-reply.cjs --platform x --tweet-id "${r.tweetId}" --text "your reply"`);
    }
  }

  // Mentions
  if (byType.mention) {
    lines.push(`\n== MENTIONS (${byType.mention.length}) ==`);
    for (const m of byType.mention) {
      const platform = m.platform === 'bluesky' ? '[BSky]' : '[X]';
      lines.push(`${platform} @${m.author}: "${m.text}"`);
    }
  }

  // Follows
  if (byType.follow) {
    lines.push(`\n== NEW FOLLOWERS (${byType.follow.length}) ==`);
    for (const f of byType.follow) {
      lines.push(`[BSky] ${f.displayName} (@${f.author})`);
    }
  }

  // Likes/reposts (summary only)
  if (byType.like) lines.push(`\n${byType.like.length} new likes`);
  if (byType.repost) lines.push(`${byType.repost.length} new reposts`);

  return lines.join('\n');
}

async function sendSlack(text) {
  if (!SLACK_WEBHOOK) {
    console.warn('[Slack] No SLACK_WEBHOOK_URL in .env — skipping');
    return;
  }
  const payload = JSON.stringify({ text: `*Social Monitor*\n${text}` });
  const url = new URL(SLACK_WEBHOOK);
  const proto = url.protocol === 'https:' ? https : http;

  return new Promise((resolve, reject) => {
    const req = proto.request(url, { method: 'POST', headers: { 'Content-Type': 'application/json' } }, (res) => {
      res.on('data', () => {});
      res.on('end', resolve);
    });
    req.on('error', reject);
    req.write(payload);
    req.end();
  });
}

async function main() {
  const opts = parseArgs();
  const state = loadState();
  const sinceDate = parseSince(opts.since);
  let allNotifications = [];

  if (opts.platform === 'both' || opts.platform === 'bsky') {
    console.log('[Bluesky] Checking notifications...');
    const bskyNotifs = await checkBluesky(state, sinceDate);
    allNotifications = allNotifications.concat(bskyNotifs);
    console.log(`[Bluesky] Found ${bskyNotifs.length} new notifications`);
  }

  if (opts.platform === 'both' || opts.platform === 'x') {
    console.log('[X] Checking mentions...');
    const xNotifs = await checkTwitter(state, sinceDate);
    allNotifications = allNotifications.concat(xNotifs);
    console.log(`[X] Found ${xNotifs.length} new notifications`);
  }

  saveState(state);

  const summary = formatNotifications(allNotifications);
  console.log(summary);

  // Save full results for reference
  if (allNotifications.length > 0) {
    fs.writeFileSync('/tmp/social-monitor-latest.json', JSON.stringify(allNotifications, null, 2));
    console.log(`\nFull data saved to /tmp/social-monitor-latest.json`);
  }

  if (opts.slack && allNotifications.length > 0) {
    await sendSlack(summary);
    console.log('[Slack] Notification sent');
  }

  // Return for programmatic use
  return allNotifications;
}

// Export for use by blog-agent
module.exports = { checkBluesky, checkTwitter, formatNotifications };

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