← back to Norma

agents/twitter-agent/lib/twitter.js

248 lines

/**
 * Twitter/X API v2 Helper Functions
 *
 * Wraps the twitter-api-v2 library for search, post, reply, and metrics.
 * All methods gracefully handle missing credentials.
 * Credentials loaded from DB first, with env var fallback.
 */

const { TwitterApi } = require('twitter-api-v2');
const { loadCredentials } = require('../../shared/credentials');

// Default hashtags for advocacy topics
const ADVOCACY_HASHTAGS = [
  '#CivicAction',
  '#Advocacy',
  '#NonprofitAdvocacy',
  '#SocialJustice',
  '#CivicEngagement',
  '#GrassrootsAction',
  '#PolicyReform',
  '#CommunityAction',
];

/**
 * Check if Twitter API credentials are configured (DB-first, env-fallback).
 * @returns {Promise<boolean>}
 */
async function hasCredentials() {
  const creds = await loadCredentials('twitter');
  return !!(
    creds?.TWITTER_BEARER_TOKEN ||
    (creds?.TWITTER_API_KEY && creds?.TWITTER_ACCESS_TOKEN)
  );
}

/**
 * Create a read-only Twitter client (uses bearer token).
 * @returns {Promise<TwitterApi|null>}
 */
let _bearerWarnedOnce = false;
async function getReadClient() {
  const creds = await loadCredentials('twitter');
  const bearerToken = creds?.TWITTER_BEARER_TOKEN || '';
  if (!bearerToken) {
    if (!_bearerWarnedOnce) {
      console.warn('[twitter] No TWITTER_BEARER_TOKEN configured — read operations will fail (warning suppressed for subsequent calls)');
      _bearerWarnedOnce = true;
    }
    return null;
  }
  return new TwitterApi(bearerToken);
}

/**
 * Create a read-write Twitter client (uses OAuth 1.0a user tokens).
 * @returns {Promise<TwitterApi|null>}
 */
async function getWriteClient() {
  const creds = await loadCredentials('twitter');
  const apiKey = creds?.TWITTER_API_KEY || '';
  const apiSecret = creds?.TWITTER_API_SECRET || '';
  const accessToken = creds?.TWITTER_ACCESS_TOKEN || '';
  const accessSecret = creds?.TWITTER_ACCESS_SECRET || '';

  if (!apiKey || !apiSecret || !accessToken || !accessSecret) {
    console.warn('[twitter] OAuth 1.0a credentials not fully configured — write operations will fail');
    return null;
  }

  return new TwitterApi({
    appKey: apiKey,
    appSecret: apiSecret,
    accessToken: accessToken,
    accessSecret: accessSecret,
  });
}

/**
 * Search recent tweets matching a query.
 *
 * @param {string} query - Search query (supports Twitter search operators)
 * @param {number} [maxResults=10] - Max results (10-100)
 * @returns {Promise<{ tweets: Array, meta: Object }>}
 */
async function searchTweets(query, maxResults = 10) {
  const client = await getReadClient();
  if (!client) {
    return {
      tweets: [],
      meta: { error: 'No Twitter credentials configured' },
      simulated: true,
    };
  }

  try {
    const result = await client.v2.search(query, {
      max_results: Math.min(Math.max(maxResults, 10), 100),
      'tweet.fields': [
        'created_at',
        'public_metrics',
        'author_id',
        'conversation_id',
        'in_reply_to_user_id',
        'lang',
      ],
      'user.fields': ['name', 'username', 'public_metrics'],
      expansions: ['author_id'],
    });

    const tweets = (result.data?.data || []).map((tweet) => {
      const author = result.includes?.users?.find((u) => u.id === tweet.author_id);
      return {
        id: tweet.id,
        text: tweet.text,
        created_at: tweet.created_at,
        author: author
          ? {
              id: author.id,
              name: author.name,
              username: author.username,
              followers: author.public_metrics?.followers_count || 0,
            }
          : null,
        metrics: tweet.public_metrics || {},
        conversation_id: tweet.conversation_id,
        lang: tweet.lang,
      };
    });

    return {
      tweets,
      meta: {
        result_count: result.data?.meta?.result_count || 0,
        newest_id: result.data?.meta?.newest_id,
        oldest_id: result.data?.meta?.oldest_id,
        next_token: result.data?.meta?.next_token,
      },
    };
  } catch (err) {
    console.error(`[twitter] searchTweets error:`, err.message);

    // Check for rate limit response
    if (err.code === 429 || err.rateLimit) {
      return {
        tweets: [],
        meta: {
          error: 'Twitter API rate limited',
          reset: err.rateLimit?.reset,
        },
        rate_limited: true,
      };
    }

    throw err;
  }
}

/**
 * Post a tweet.
 *
 * @param {string} text - Tweet text (max 280 chars)
 * @param {string} [replyTo] - Tweet ID to reply to (optional)
 * @returns {Promise<{ id: string, text: string }>}
 */
async function postTweet(text, replyTo = null) {
  const client = await getWriteClient();
  if (!client) {
    return {
      id: null,
      text,
      simulated: true,
      note: 'No Twitter write credentials configured — tweet not posted',
    };
  }

  try {
    const params = {};
    if (replyTo) {
      params.reply = { in_reply_to_tweet_id: replyTo };
    }

    const result = await client.v2.tweet(text, params);

    return {
      id: result.data.id,
      text: result.data.text,
      posted: true,
    };
  } catch (err) {
    console.error(`[twitter] postTweet error:`, err.message);

    if (err.code === 429) {
      return {
        id: null,
        text,
        rate_limited: true,
        error: 'Twitter API rate limited',
      };
    }

    throw err;
  }
}

/**
 * Get engagement metrics for a tweet.
 *
 * @param {string} tweetId - Tweet ID
 * @returns {Promise<{ id: string, metrics: Object, text: string }>}
 */
async function getTweetMetrics(tweetId) {
  const client = await getReadClient();
  if (!client) {
    return {
      id: tweetId,
      metrics: {},
      simulated: true,
      note: 'No Twitter credentials configured',
    };
  }

  try {
    const result = await client.v2.singleTweet(tweetId, {
      'tweet.fields': ['public_metrics', 'created_at', 'author_id'],
    });

    return {
      id: tweetId,
      text: result.data.text,
      created_at: result.data.created_at,
      metrics: result.data.public_metrics || {},
    };
  } catch (err) {
    console.error(`[twitter] getTweetMetrics error for ${tweetId}:`, err.message);
    throw err;
  }
}

module.exports = {
  searchTweets,
  postTweet,
  getTweetMetrics,
  hasCredentials,
  getReadClient,
  getWriteClient,
  ADVOCACY_HASHTAGS,
};