← back to Norma

agents/reddit-agent/skills/discover.js

179 lines

/**
 * Reddit Discover Skill
 *
 * Searches relevant subreddits for advocacy discussions.
 * Uses Reddit's public JSON API (no auth required for reading).
 *
 * Rate limit: 60/min (Reddit API standard)
 */

const { RateLimiter } = require('../../shared/rate-limiter');
const { logAction } = require('../../shared/audit-logger');
const { pushSocialFeed, pushTrending } = require('../../shared/pulse-reporter');
const { searchSubreddit, scoreRelevance, SUBREDDITS, KEYWORDS } = require('../lib/reddit');

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

const rateLimiter = new RateLimiter(AGENT, PLATFORM);

/**
 * @param {Object} body - Request body
 * @param {string} [body.query='nonprofit advocacy'] - Search query
 * @param {string[]} [body.subreddits] - Specific subreddits to search (defaults to all)
 * @param {number} [body.max_results=25] - Max results per subreddit
 * @param {string} [body.sort='new'] - Sort order: new, relevance, hot, top
 * @param {boolean} [body.push_to_pulse=true] - Push results to Pulse
 * @returns {Promise<Object>}
 */
module.exports = async function discover(body) {
  const query = body.query || 'nonprofit advocacy';
  const subreddits = body.subreddits || SUBREDDITS;
  const maxResults = Math.min(body.max_results || 25, 100);
  const sort = body.sort || 'new';
  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,
      content: query,
      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,
    };
  }

  const allResults = [];

  for (const subreddit of subreddits) {
    try {
      const posts = await searchSubreddit(subreddit, query, sort, maxResults);
      await rateLimiter.recordAction('search');

      // Score each post for relevance
      const scored = posts.map((post) => ({
        ...post,
        relevance_score: scoreRelevance(post),
      }));

      // Filter to relevant posts only (score > 10)
      const relevant = scored.filter((p) => p.relevance_score > 10);

      // Sort by relevance
      relevant.sort((a, b) => b.relevance_score - a.relevance_score);

      allResults.push({
        subreddit,
        query,
        total_found: posts.length,
        relevant_count: relevant.length,
        posts: relevant,
      });
    } catch (err) {
      console.error(`[${AGENT}] Search failed for r/${subreddit}:`, err.message);
      allResults.push({
        subreddit,
        query,
        total_found: 0,
        relevant_count: 0,
        posts: [],
        error: err.message,
      });
    }
  }

  // Aggregate stats
  const totalFound = allResults.reduce((sum, r) => sum + r.total_found, 0);
  const totalRelevant = allResults.reduce((sum, r) => sum + r.relevant_count, 0);

  // Get top posts across all subreddits
  const topPosts = allResults
    .flatMap((r) => r.posts)
    .sort((a, b) => b.relevance_score - a.relevance_score)
    .slice(0, 10);

  // Extract trending keywords from post titles
  const keywordCounts = {};
  allResults.flatMap((r) => r.posts).forEach((post) => {
    const text = `${post.title} ${post.selftext}`.toLowerCase();
    for (const kw of KEYWORDS) {
      if (text.includes(kw.toLowerCase())) {
        keywordCounts[kw] = (keywordCounts[kw] || 0) + 1;
      }
    }
  });

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

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

    // Push high-relevance posts as social feed items
    for (const post of topPosts.slice(0, 5)) {
      await pushSocialFeed({
        agent: AGENT,
        platform: PLATFORM,
        type: 'discussion',
        title: `r/${post.subreddit}: ${post.title.substring(0, 100)}`,
        url: post.permalink,
        content: post.selftext.substring(0, 300),
        metadata: {
          post_id: post.id,
          score: post.score,
          num_comments: post.num_comments,
          relevance_score: post.relevance_score,
          subreddit: post.subreddit,
        },
      });
    }
  }

  // Log the action
  await logAction({
    agent: AGENT,
    actionType: 'discover',
    platform: PLATFORM,
    content: query,
    responseData: {
      query,
      subreddits_searched: subreddits.length,
      total_found: totalFound,
      total_relevant: totalRelevant,
      trending_keywords: trending.length,
    },
    status: 'success',
  });

  return {
    query,
    subreddits_searched: subreddits.length,
    total_found: totalFound,
    total_relevant: totalRelevant,
    results: allResults,
    top_posts: topPosts,
    trending,
    fetched_at: new Date().toISOString(),
  };
};