← back to Norma

agents/twitter-agent/skills/monitor.js

241 lines

/**
 * Twitter Monitor Skill
 *
 * Monitors configured hashtags and keywords from sdcc_agent_monitors table.
 * Fetches recent tweets using Twitter API v2 search.
 *
 * Rate limit: uses search limit (60/15min)
 */

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 { query: dbQuery } = require('../../shared/db');

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

const rateLimiter = new RateLimiter(AGENT, PLATFORM);

/**
 * @param {Object} body - Request body
 * @param {string[]} [body.hashtags] - Override hashtags to monitor
 * @param {string[]} [body.keywords] - Additional keywords to search
 * @param {number} [body.max_results=10] - Max results per query
 * @param {boolean} [body.push_to_pulse=true] - Push results to Pulse
 * @returns {Promise<Object>}
 */
module.exports = async function monitor(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: 'monitor',
      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,
    };
  }

  // Get active monitors from database
  let monitors = [];
  try {
    const { rows } = await dbQuery(
      `SELECT id, target, keywords, monitor_type
       FROM sdcc_agent_monitors
       WHERE agent = $1 AND platform = $2 AND is_active = true`,
      [AGENT, PLATFORM]
    );
    monitors = rows;
  } catch (err) {
    console.error(`[${AGENT}] Failed to fetch monitors:`, err.message);
  }

  // Build search queries from monitors and/or body params
  const queries = [];

  // Add monitor-based queries
  for (const m of monitors) {
    if (m.monitor_type === 'hashtag') {
      queries.push({
        query: `${m.target} -is:retweet lang:en`,
        monitor_id: m.id,
        source: 'db_monitor',
      });
    } else if (m.monitor_type === 'keyword' && m.keywords) {
      queries.push({
        query: `(${m.keywords.join(' OR ')}) -is:retweet lang:en`,
        monitor_id: m.id,
        source: 'db_monitor',
      });
    }
  }

  // Add body-specified queries
  if (body.hashtags && body.hashtags.length > 0) {
    queries.push({
      query: `(${body.hashtags.join(' OR ')}) -is:retweet lang:en`,
      source: 'request',
    });
  }

  if (body.keywords && body.keywords.length > 0) {
    queries.push({
      query: `(${body.keywords.join(' OR ')}) -is:retweet lang:en`,
      source: 'request',
    });
  }

  // Fallback: use default hashtags if no queries configured
  if (queries.length === 0) {
    queries.push({
      query: `(${ADVOCACY_HASHTAGS.slice(0, 3).join(' OR ')}) -is:retweet lang:en`,
      source: 'default',
    });
  }

  // Execute searches
  const allResults = [];
  for (const q of queries) {
    try {
      const searchResult = await searchTweets(q.query, maxResults);
      await rateLimiter.recordAction('search');

      const tweets = searchResult.tweets || [];

      // Compute engagement score for each tweet
      const scored = tweets.map((t) => {
        const m = t.metrics || {};
        const engagement =
          (m.like_count || 0) * 1 +
          (m.retweet_count || 0) * 2 +
          (m.reply_count || 0) * 1.5 +
          (m.quote_count || 0) * 2;
        return { ...t, engagement_score: engagement };
      });

      // Sort by engagement
      scored.sort((a, b) => b.engagement_score - a.engagement_score);

      allResults.push({
        query: q.query,
        source: q.source,
        monitor_id: q.monitor_id || null,
        count: scored.length,
        tweets: scored,
        simulated: searchResult.simulated || false,
      });

      // Update monitor last_checked_at and items_found
      if (q.monitor_id) {
        try {
          await dbQuery(
            `UPDATE sdcc_agent_monitors
             SET last_checked_at = NOW(), items_found = items_found + $1, updated_at = NOW()
             WHERE id = $2`,
            [scored.length, q.monitor_id]
          );
        } catch (err) {
          console.error(`[${AGENT}] Failed to update monitor ${q.monitor_id}:`, err.message);
        }
      }
    } catch (err) {
      console.error(`[${AGENT}] Search failed for query "${q.query}":`, err.message);
      allResults.push({
        query: q.query,
        source: q.source,
        count: 0,
        tweets: [],
        error: err.message,
      });
    }
  }

  // Aggregate trending hashtags across all results
  const hashtagCounts = {};
  allResults.forEach((r) => {
    r.tweets.forEach((t) => {
      const matches = t.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, 15)
    .map(([tag, count]) => ({
      topic: tag,
      platform: PLATFORM,
      volume: count,
      sentiment: 'neutral',
    }));

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

    // Push top-engagement tweets
    const topTweets = allResults
      .flatMap((r) => r.tweets)
      .sort((a, b) => b.engagement_score - a.engagement_score)
      .slice(0, 5);

    for (const tweet of topTweets) {
      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,
          engagement_score: tweet.engagement_score,
          metrics: tweet.metrics,
        },
      });
    }
  }

  const totalTweets = allResults.reduce((sum, r) => sum + r.count, 0);

  // Log the action
  await logAction({
    agent: AGENT,
    actionType: 'monitor',
    platform: PLATFORM,
    responseData: {
      queries_executed: allResults.length,
      total_tweets: totalTweets,
      trending_hashtags: trending.length,
      monitors_checked: monitors.length,
    },
    status: 'success',
  });

  return {
    queries_executed: allResults.length,
    total_tweets: totalTweets,
    results: allResults,
    trending,
    monitored_at: new Date().toISOString(),
  };
};