← back to Norma

agents/twitter-agent/skills/discover.js

148 lines

/**
 * Twitter Discover Skill
 *
 * Searches X/Twitter for advocacy hashtags and relevant conversations.
 * Rate limit: 60/15min (Twitter API v2 search limit)
 */

const { RateLimiter } = require('../../shared/rate-limiter');
const { logAction } = require('../../shared/audit-logger');
const { pushSocialFeed, pushTrending } = require('../../shared/pulse-reporter');
const { searchTweets, ADVOCACY_HASHTAGS } = require('../lib/twitter');

const AGENT = 'twitter-agent';
const PLATFORM = 'twitter';

const rateLimiter = new RateLimiter(AGENT, PLATFORM);

/**
 * @param {Object} body - Request body
 * @param {string} [body.query] - Custom search query (overrides default hashtags)
 * @param {string[]} [body.hashtags] - Specific hashtags to search
 * @param {number} [body.max_results=10] - Max results per query (10-100)
 * @param {boolean} [body.push_to_pulse=true] - Push results to Pulse
 * @returns {Promise<Object>}
 */
module.exports = async function discover(body) {
  const maxResults = Math.min(body.max_results || 10, 100);
  const pushToPulse = body.push_to_pulse !== false;

  // Check rate limit
  const limit = await rateLimiter.checkLimit('search');
  if (!limit.allowed) {
    await logAction({
      agent: AGENT,
      actionType: 'discover',
      platform: PLATFORM,
      status: 'rate_limited',
      errorMessage: `Rate limited. Retry after ${Math.ceil(limit.retryAfterMs / 1000)}s`,
    });

    return {
      rate_limited: true,
      retry_after_seconds: Math.ceil(limit.retryAfterMs / 1000),
      current: limit.current,
      max: limit.max,
    };
  }

  // Build search query
  let query;
  if (body.query) {
    query = body.query;
  } else {
    const hashtags = body.hashtags || ADVOCACY_HASHTAGS.slice(0, 3);
    query = hashtags.join(' OR ') + ' -is:retweet lang:en';
  }

  // Search Twitter
  const searchResult = await searchTweets(query, maxResults);

  // Record the rate limit action
  await rateLimiter.recordAction('search');

  // Calculate engagement stats
  const tweets = searchResult.tweets || [];
  const totalEngagement = tweets.reduce((sum, t) => {
    const m = t.metrics || {};
    return sum + (m.like_count || 0) + (m.retweet_count || 0) + (m.reply_count || 0);
  }, 0);

  // Identify trending topics from tweet content
  const hashtagCounts = {};
  tweets.forEach((tweet) => {
    const matches = tweet.text.match(/#\w+/g) || [];
    matches.forEach((tag) => {
      const lower = tag.toLowerCase();
      hashtagCounts[lower] = (hashtagCounts[lower] || 0) + 1;
    });
  });

  const trending = Object.entries(hashtagCounts)
    .sort(([, a], [, b]) => b - a)
    .slice(0, 10)
    .map(([tag, count]) => ({
      topic: tag,
      platform: PLATFORM,
      volume: count,
      sentiment: 'neutral',
    }));

  // Push to Pulse if enabled
  if (pushToPulse) {
    // Push trending topics
    if (trending.length > 0) {
      await pushTrending(trending);
    }

    // Push high-engagement tweets as social feed items
    const highEngagement = tweets.filter((t) => {
      const m = t.metrics || {};
      return (m.like_count || 0) + (m.retweet_count || 0) >= 5;
    });

    for (const tweet of highEngagement.slice(0, 5)) {
      await pushSocialFeed({
        agent: AGENT,
        platform: PLATFORM,
        type: 'mention',
        title: `@${tweet.author?.username || 'unknown'}: ${tweet.text.substring(0, 80)}...`,
        url: `https://x.com/${tweet.author?.username || 'i'}/status/${tweet.id}`,
        content: tweet.text,
        metadata: {
          tweet_id: tweet.id,
          metrics: tweet.metrics,
          author_followers: tweet.author?.followers || 0,
        },
      });
    }
  }

  // Log the action
  await logAction({
    agent: AGENT,
    actionType: 'discover',
    platform: PLATFORM,
    content: query,
    responseData: {
      query,
      tweetCount: tweets.length,
      totalEngagement,
      trendingHashtags: trending.length,
      simulated: searchResult.simulated || false,
    },
    status: 'success',
  });

  return {
    query,
    count: tweets.length,
    tweets,
    trending,
    total_engagement: totalEngagement,
    meta: searchResult.meta,
    simulated: searchResult.simulated || false,
    fetched_at: new Date().toISOString(),
  };
};