← back to Dear Bubbe Nextjs

scripts/twitter-smart-poster.js

416 lines

#!/usr/bin/env node

const fs = require('fs').promises;
const path = require('path');
const axios = require('axios');
const dotenv = require('dotenv');

// Load environment variables
dotenv.config({ path: path.join(__dirname, '..', '.env.local') });

const LOGS_DIR = path.join(__dirname, '..', 'logs');
const POSTED_FILE = path.join(LOGS_DIR, 'posted-tweets.json');
const STATS_FILE = path.join(LOGS_DIR, 'twitter-stats.json');
const LOG_FILE = path.join(LOGS_DIR, 'twitter-poster.log');

// Twitter API configuration
const TWITTER_API_URL = 'https://api.twitter.com/2/tweets';
const TWITTER_BEARER_TOKEN = process.env.TWITTER_BEARER_TOKEN;

// Posting configuration
const MAX_DAILY_POSTS = 100;
const MIN_POST_INTERVAL = 3 * 60 * 1000; // 3 minutes
const MAX_TWEET_LENGTH = 280;

// Viral hashtags for engagement
const VIRAL_HASHTAGS = [
  '#JewishTwitter', '#JewishHumor', '#Bubbe', '#NoFilter',
  '#SavageGrandma', '#TruthHurts', '#BubbeAI', '#AIGrandma',
  '#JewishMom', '#GuiltTrip', '#Yiddish', '#Comedy',
  '#Sarcasm', '#BrutalHonesty', '#Roasted', '#RealTalk'
];

// Celebrity/influencer handles to occasionally @ mention
const MENTION_TARGETS = [
  '@jerryseinfeld', '@SethRogen', '@AmySchumer', '@BillyEichner',
  '@SarahKSilverman', '@alydenisof', '@mindykaling', '@azizansari',
  '@rickygervais', '@StephenCurry30', '@jimmyfallon', '@TheEllenShow',
  '@Oprah', '@GordonRamsay', '@DrPhil', '@TheRock'
];

// Time slots for optimal engagement (EST)
const PEAK_HOURS = [
  { hour: 7, weight: 3 },   // Morning commute
  { hour: 8, weight: 3 },
  { hour: 12, weight: 4 },  // Lunch break
  { hour: 13, weight: 3 },
  { hour: 17, weight: 4 },  // Evening commute
  { hour: 18, weight: 4 },
  { hour: 19, weight: 5 },  // Prime time
  { hour: 20, weight: 5 },
  { hour: 21, weight: 4 },
  { hour: 22, weight: 3 }
];

// Bubbe's intro phrases
const INTRO_PHRASES = [
  "Bubbe's hot take:",
  "Bubbe says:",
  "Listen up, bubbeleh:",
  "Oy vey, let me tell you:",
  "Bubbe's wisdom:",
  "From Bubbe with tough love:",
  "Bubbe's brutal honesty:",
  "No filter alert:",
  "Bubbe drops truth:",
  "Real talk from Bubbe:"
];

async function loadStats() {
  try {
    const data = await fs.readFile(STATS_FILE, 'utf-8');
    return JSON.parse(data);
  } catch {
    return {
      startDate: new Date().toISOString(),
      totalPosts: 0,
      dailyPosts: {},
      engagementTracking: [],
      lastPostTime: null
    };
  }
}

async function saveStats(stats) {
  await fs.writeFile(STATS_FILE, JSON.stringify(stats, null, 2));
}

async function loadPostedTweets() {
  try {
    const data = await fs.readFile(POSTED_FILE, 'utf-8');
    return JSON.parse(data);
  } catch {
    return [];
  }
}

async function savePostedTweets(tweets) {
  await fs.writeFile(POSTED_FILE, JSON.stringify(tweets, null, 2));
}

async function log(message) {
  const timestamp = new Date().toLocaleString();
  const logMessage = `[${timestamp}] ${message}\n`;
  await fs.appendFile(LOG_FILE, logMessage);
  console.log(message);
}

function getCurrentHour() {
  const now = new Date();
  const estOffset = -5; // EST offset
  const utc = now.getTime() + (now.getTimezoneOffset() * 60000);
  const est = new Date(utc + (3600000 * estOffset));
  return est.getHours();
}

function getPostWeight() {
  const hour = getCurrentHour();
  const peakHour = PEAK_HOURS.find(p => p.hour === hour);
  return peakHour ? peakHour.weight : 1;
}

function calculatePostsForTimeSlot(remainingPosts, remainingHours) {
  const weight = getPostWeight();
  const baseRate = remainingPosts / remainingHours;
  return Math.ceil(baseRate * weight);
}

async function getRecentConversation() {
  try {
    // Get a recent interesting conversation from the database
    const conversationFiles = await fs.readdir(path.join(__dirname, '..', 'user-memories'));
    if (conversationFiles.length === 0) {
      await log('No conversation files found in user-memories');
      return null;
    }
    
    // Filter for valid .md files
    const mdFiles = conversationFiles.filter(f => f.endsWith('.md'));
    if (mdFiles.length === 0) {
      await log('No .md files found in user-memories');
      return null;
    }
    
    const randomFile = mdFiles[Math.floor(Math.random() * mdFiles.length)];
    const content = await fs.readFile(
      path.join(__dirname, '..', 'user-memories', randomFile), 
      'utf-8'
    );
    
    // Extract a good conversation snippet - look for User: and Bubbe: patterns
    const lines = content.split('\n');
    
    // Find User: and Bubbe: lines
    for (let i = 0; i < lines.length - 1; i++) {
      if (lines[i].startsWith('User:') && lines[i + 1].startsWith('Bubbe:')) {
        const userText = lines[i].replace('User:', '').trim();
        const bubbeText = lines[i + 1].replace('Bubbe:', '').trim();
        
        // Make sure we have actual content
        if (userText.length > 5 && bubbeText.length > 10) {
          await log(`Found conversation from ${randomFile}`);
          return {
            user: userText,
            bubbe: bubbeText
          };
        }
      }
    }
    
    await log(`No valid conversation found in ${randomFile}`);
    return null;
  } catch (error) {
    await log(`Error reading conversations: ${error.message}`);
    return null;
  }
}

function formatTweet(conversation, includeHashtags = true, includeMention = false) {
  const intro = INTRO_PHRASES[Math.floor(Math.random() * INTRO_PHRASES.length)];
  
  let tweet = `${intro}\n\n`;
  
  // Shorten if needed
  let userText = conversation.user;
  let bubbeText = conversation.bubbe;
  
  // Calculate available space
  let availableSpace = MAX_TWEET_LENGTH - tweet.length - 30; // Leave room for hashtags
  
  if (includeMention && Math.random() < 0.1) { // 10% chance to @ someone
    const mention = MENTION_TARGETS[Math.floor(Math.random() * MENTION_TARGETS.length)];
    availableSpace -= mention.length + 1;
    tweet = `${mention} ${tweet}`;
  }
  
  // Fit the conversation
  if (userText.length + bubbeText.length > availableSpace) {
    const halfSpace = Math.floor(availableSpace / 2);
    if (userText.length > halfSpace) {
      userText = userText.substring(0, halfSpace - 3) + '...';
    }
    if (bubbeText.length > halfSpace) {
      bubbeText = bubbeText.substring(0, halfSpace - 3) + '...';
    }
  }
  
  tweet += `User: "${userText}"\n\n`;
  tweet += `Bubbe: "${bubbeText}"\n\n`;
  
  if (includeHashtags) {
    // Add 3-4 random hashtags
    const numHashtags = 3 + Math.floor(Math.random() * 2);
    const selectedHashtags = [];
    for (let i = 0; i < numHashtags; i++) {
      const hashtag = VIRAL_HASHTAGS[Math.floor(Math.random() * VIRAL_HASHTAGS.length)];
      if (!selectedHashtags.includes(hashtag)) {
        selectedHashtags.push(hashtag);
      }
    }
    tweet += selectedHashtags.join(' ') + '\n\n';
  }
  
  tweet += '💬 Chat with Bubbe: Bubbe.AI';
  
  // Ensure we don't exceed max length
  if (tweet.length > MAX_TWEET_LENGTH) {
    tweet = tweet.substring(0, MAX_TWEET_LENGTH - 3) + '...';
  }
  
  return tweet;
}

async function postToTwitter(tweet) {
  try {
    // For now, simulate posting since we need OAuth 2.0 setup
    await log(`WOULD POST: ${tweet.substring(0, 100)}...`);
    return { success: true, id: Date.now().toString() };
    
    // Real posting code (needs OAuth 2.0 setup):
    /*
    const response = await axios.post(
      TWITTER_API_URL,
      { text: tweet },
      {
        headers: {
          'Authorization': `Bearer ${TWITTER_BEARER_TOKEN}`,
          'Content-Type': 'application/json'
        }
      }
    );
    return { success: true, id: response.data.data.id };
    */
  } catch (error) {
    await log(`Error posting tweet: ${error.message}`);
    return { success: false, error: error.message };
  }
}

async function runSmartPoster() {
  await log('Smart Twitter poster starting...');
  
  const stats = await loadStats();
  const postedTweets = await loadPostedTweets();
  
  // Calculate today's posts
  const today = new Date().toDateString();
  const todaysPosts = stats.dailyPosts[today] || 0;
  
  if (todaysPosts >= MAX_DAILY_POSTS) {
    await log(`Already posted ${MAX_DAILY_POSTS} tweets today. Waiting for tomorrow.`);
    return;
  }
  
  // Check if enough time has passed since last post
  if (stats.lastPostTime) {
    const timeSinceLastPost = Date.now() - new Date(stats.lastPostTime).getTime();
    if (timeSinceLastPost < MIN_POST_INTERVAL) {
      const waitTime = MIN_POST_INTERVAL - timeSinceLastPost;
      await log(`Waiting ${Math.round(waitTime / 1000)} seconds before next post...`);
      setTimeout(() => runSmartPoster(), waitTime);
      return;
    }
  }
  
  // Get conversation to post
  const conversation = await getRecentConversation();
  if (!conversation) {
    await log('No conversation available to post. Trying again in 5 minutes...');
    setTimeout(() => runSmartPoster(), 5 * 60 * 1000);
    return;
  }
  
  // Format and post tweet
  const includeMention = Math.random() < 0.15; // 15% chance to @ someone
  const tweet = formatTweet(conversation, true, includeMention);
  
  const result = await postToTwitter(tweet);
  
  if (result.success) {
    // Update stats
    stats.totalPosts++;
    stats.dailyPosts[today] = todaysPosts + 1;
    stats.lastPostTime = new Date().toISOString();
    stats.engagementTracking.push({
      id: result.id,
      time: new Date().toISOString(),
      hasHashtags: true,
      hasMention: includeMention
    });
    
    // Save posted tweet
    postedTweets.push({
      id: result.id,
      text: tweet,
      timestamp: new Date().toISOString()
    });
    
    await saveStats(stats);
    await savePostedTweets(postedTweets);
    
    await log(`Posted tweet ${stats.dailyPosts[today]}/${MAX_DAILY_POSTS} for today`);
    
    // Calculate next post time based on remaining posts and time
    const remainingPosts = MAX_DAILY_POSTS - stats.dailyPosts[today];
    const hoursUntilMidnight = 24 - new Date().getHours();
    const postsThisHour = calculatePostsForTimeSlot(remainingPosts, hoursUntilMidnight);
    
    const intervalForHour = Math.max(
      MIN_POST_INTERVAL,
      Math.floor(60 * 60 * 1000 / postsThisHour)
    );
    
    await log(`Next post in ${Math.round(intervalForHour / 1000)} seconds`);
    setTimeout(() => runSmartPoster(), intervalForHour);
  } else {
    // Retry in 5 minutes if failed
    await log('Failed to post. Retrying in 5 minutes...');
    setTimeout(() => runSmartPoster(), 5 * 60 * 1000);
  }
}

async function generateWeeklyReport() {
  const stats = await loadStats();
  
  const report = {
    reportDate: new Date().toISOString(),
    weekStarted: stats.startDate,
    totalPosts: stats.totalPosts,
    dailyBreakdown: stats.dailyPosts,
    averagePostsPerDay: stats.totalPosts / 7,
    peakPostingTimes: analyzePeakTimes(stats.engagementTracking),
    hashtagPerformance: 'Tracking engagement on viral hashtags',
    mentionSuccess: `${stats.engagementTracking.filter(e => e.hasMention).length} celebrity mentions`,
    recommendations: [
      'Continue posting during peak hours (7-9pm EST)',
      'Increase celebrity mentions for viral potential',
      'Test new hashtag combinations'
    ]
  };
  
  const reportPath = path.join(LOGS_DIR, `weekly-report-${Date.now()}.json`);
  await fs.writeFile(reportPath, JSON.stringify(report, null, 2));
  
  await log(`Weekly report generated: ${reportPath}`);
  console.log('📊 WEEKLY REPORT FOR STEVE:');
  console.log(JSON.stringify(report, null, 2));
  
  return report;
}

function analyzePeakTimes(tracking) {
  const hourCounts = {};
  tracking.forEach(post => {
    const hour = new Date(post.time).getHours();
    hourCounts[hour] = (hourCounts[hour] || 0) + 1;
  });
  
  const sorted = Object.entries(hourCounts)
    .sort((a, b) => b[1] - a[1])
    .slice(0, 3);
  
  return sorted.map(([hour, count]) => ({
    hour: parseInt(hour),
    posts: count
  }));
}

// Check if it's time for weekly report (every 7 days)
async function checkWeeklyReport() {
  const stats = await loadStats();
  const daysSinceStart = Math.floor(
    (Date.now() - new Date(stats.startDate).getTime()) / (1000 * 60 * 60 * 24)
  );
  
  if (daysSinceStart > 0 && daysSinceStart % 7 === 0) {
    await generateWeeklyReport();
  }
}

// Main execution
async function main() {
  // Ensure directories exist
  await fs.mkdir(LOGS_DIR, { recursive: true });
  
  await log('Twitter Smart Poster initialized with viral strategy');
  await log(`Max ${MAX_DAILY_POSTS} posts/day, min ${MIN_POST_INTERVAL/1000}s between posts`);
  
  // Start the smart posting loop
  runSmartPoster();
  
  // Check for weekly report every day at midnight
  setInterval(checkWeeklyReport, 24 * 60 * 60 * 1000);
}

// Start the service
main().catch(console.error);