← back to Animate Museum Posts

src/postToX.js

222 lines

import { TwitterApi } from 'twitter-api-v2';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import dotenv from 'dotenv';
import { sendPostNotification } from './sendEmail.js';

dotenv.config();

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

// Rotating intro phrases
const INTRO_PHRASES = [
  'Public Domain Image of the Day',
  'Art Spotlight',
  'Masterpiece of the Day',
  'From the Archives',
  'Classical Art Feature',
  'Timeless Beauty',
  'Art History Moment',
  'Museum Treasure'
];

// Art period hashtags
function getArtHashtags(metadata) {
  const hashtags = ['#PublicDomain', '#Art'];
  const title = (metadata.title || '').toLowerCase();
  const artist = (metadata.artist || '').toLowerCase();
  const year = parseInt(metadata.year) || 0;

  // Period-based hashtags
  if (year < 1500) hashtags.push('#Medieval', '#Renaissance');
  else if (year < 1700) hashtags.push('#Baroque');
  else if (year < 1800) hashtags.push('#Neoclassical');
  else if (year < 1870) hashtags.push('#Romanticism');
  else if (year < 1910) hashtags.push('#Impressionism');
  else if (year < 1950) hashtags.push('#ModernArt');

  // Artist-based hashtags
  if (artist.includes('van gogh')) hashtags.push('#VanGogh');
  if (artist.includes('rembrandt')) hashtags.push('#Rembrandt');
  if (artist.includes('vermeer')) hashtags.push('#Vermeer');
  if (artist.includes('monet')) hashtags.push('#Monet');

  // Subject-based hashtags
  if (title.includes('portrait')) hashtags.push('#Portrait');
  if (title.includes('landscape')) hashtags.push('#Landscape');
  if (title.includes('still life') || title.includes('flower') || title.includes('sunflower')) hashtags.push('#StillLife');

  // Museum hashtag
  if (metadata.museum?.includes('Met')) hashtags.push('#MetMuseum');
  if (metadata.museum?.includes('Rijks')) hashtags.push('#Rijksmuseum');
  if (metadata.museum?.includes('Chicago')) hashtags.push('#ArtInstituteChicago');

  return hashtags.slice(0, 5).join(' '); // Max 5 hashtags
}

function generateTweetText(metadata) {
  const intro = INTRO_PHRASES[Math.floor(Math.random() * INTRO_PHRASES.length)];
  const hashtags = getArtHashtags(metadata);
  const museum = metadata.museum || metadata.sourceMuseum || 'Public Domain';

  const tweetText = `${intro}

"${metadata.title}"
${metadata.artist} (${metadata.year})

${museum}

${hashtags}`;

  // Ensure under 280 chars
  if (tweetText.length > 280) {
    // Truncate title if needed
    const shortTitle = metadata.title.length > 30
      ? metadata.title.substring(0, 27) + '...'
      : metadata.title;
    return `${intro}\n\n"${shortTitle}"\n${metadata.artist}\n\n${hashtags}`;
  }

  return tweetText;
}

function generateAltText(metadata) {
  const description = metadata.description || '';
  const period = metadata.period || metadata.style || '';

  let altText = `${metadata.title} by ${metadata.artist}, ${metadata.year}.`;

  if (period) altText += ` ${period}.`;
  if (description && description.length < 500) altText += ` ${description}`;

  // Twitter alt text max is 1000 chars
  return altText.substring(0, 1000);
}

export async function postToX(animationResult) {
  try {
    if (!process.env.TWITTER_API_KEY || !process.env.TWITTER_ACCESS_TOKEN) {
      throw new Error('Twitter API credentials not configured');
    }

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

    const metadata = animationResult.artworkMetadata;
    // Use animated video/image if available, otherwise use original
    const mediaPath = animationResult.animatedVideo || animationResult.animatedImage || animationResult.originalImage;

    if (!fs.existsSync(mediaPath)) {
      throw new Error(`Media file not found: ${mediaPath}`);
    }

    const tweetText = generateTweetText(metadata);

    try {
      console.log(`Uploading media: ${path.basename(mediaPath)}`);
      const mediaId = await client.v1.uploadMedia(mediaPath);

      // Add alt text for accessibility
      const altText = generateAltText(metadata);
      try {
        await client.v1.createMediaMetadata(mediaId, { alt_text: { text: altText } });
        console.log('Alt text added for accessibility');
      } catch (altError) {
        console.log('Could not add alt text:', altError.message);
      }

      const tweet = await client.v2.tweet({
        text: tweetText,
        media: {
          media_ids: [mediaId]
        }
      });

      console.log('Posted to X.com successfully!');
      console.log(`Tweet ID: ${tweet.data.id}`);
      console.log(`Tweet text: ${tweetText}`);

      await sendPostNotification(tweetText, metadata, mediaPath);

      const logEntry = {
        timestamp: new Date().toISOString(),
        tweetId: tweet.data.id,
        tweetText: tweetText,
        artwork: metadata,
        mediaPath: mediaPath
      };

      const logsDir = path.join(__dirname, '..', 'logs');
      if (!fs.existsSync(logsDir)) {
        fs.mkdirSync(logsDir, { recursive: true });
      }

      const logFile = path.join(logsDir, 'posts.log');
      fs.appendFileSync(logFile, JSON.stringify(logEntry) + '\n');

      return tweet;

    } catch (uploadError) {
      console.error('Media upload failed:', uploadError.message);
      if (uploadError.data) {
        console.error('Error details:', uploadError.data);
      }
      console.log('Falling back to text-only tweet...');

      const textOnlyTweet = await client.v2.tweet({
        text: tweetText
      });

      console.log('Posted text-only tweet to X.com');
      console.log(`Tweet ID: ${textOnlyTweet.data.id}`);

      await sendPostNotification(tweetText, metadata, null);

      return textOnlyTweet;
    }

  } catch (error) {
    console.error('Error posting to X.com:', error.message);

    if (error.code === 401) {
      console.error('Authentication failed. Please check your Twitter API credentials.');
    } else if (error.code === 403) {
      console.error('Permission denied. Your app may need Elevated access for media upload.');
      console.error('Apply for Elevated access at: https://developer.twitter.com/en/portal/products/elevated');
    }

    throw error;
  }
}

export async function testCredentials() {
  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_SECRET,
    });

    const user = await client.v2.me();
    console.log('Connected to X.com as:', user.data.username);

    try {
      const limits = await client.v2.rateLimitStatus();
      console.log('API access level verified');
    } catch (e) {
      console.log('Note: App may need Elevated access for media uploads');
    }

    return true;
  } catch (error) {
    console.error('Failed to connect to X.com:', error.message);
    return false;
  }
}