← back to Norma

agents/reddit-agent/lib/reddit.js

445 lines

/**
 * Reddit API Helper Functions
 *
 * Uses Reddit's public JSON API for read operations (no auth needed).
 * Uses snoowrap for authenticated write operations (post, reply).
 * All methods gracefully handle missing credentials.
 */

const fetch = require('node-fetch');

// Target subreddits for advocacy discussions
const SUBREDDITS = [
  'nonprofit',
  'activism',
  'civicplatform',
  'personalfinance',
  'politics',
  'lostgeneration',
];

// Keywords for relevance scoring
const KEYWORDS = [
  'nonprofit',
  'advocacy',
  'civic engagement',
  'social justice',
  'community organizing',
  'policy change',
  'petition',
  'grassroots',
  'public interest',
  'economic justice',
  'education policy',
  'consumer protection',
  'civic action',
  'social impact',
  'public benefits',
  'nonprofit advocacy',
  'community action',
  'civic participation',
  'policy reform',
  'public policy',
  'social change',
];

const USER_AGENT = process.env.REDDIT_USER_AGENT || 'Norma-Reddit-Agent/1.0';
const BASE_URL = 'https://www.reddit.com';

// Rate limit: wait between requests to Reddit JSON API
let lastRequestTime = 0;
const MIN_REQUEST_INTERVAL = 1100; // ~1 request/sec to stay under 60/min

/**
 * Rate-limit internal requests to Reddit's JSON API.
 */
async function _throttle() {
  const now = Date.now();
  const elapsed = now - lastRequestTime;
  if (elapsed < MIN_REQUEST_INTERVAL) {
    await new Promise((r) => setTimeout(r, MIN_REQUEST_INTERVAL - elapsed));
  }
  lastRequestTime = Date.now();
}

/**
 * Make a GET request to Reddit's JSON API.
 *
 * @param {string} path - URL path (e.g. /r/nonprofit/hot.json)
 * @param {Object} [params={}] - Query parameters
 * @returns {Promise<Object>} Parsed JSON response
 */
async function _redditGet(path, params = {}) {
  await _throttle();

  const url = new URL(path, BASE_URL);
  for (const [key, val] of Object.entries(params)) {
    url.searchParams.set(key, String(val));
  }

  const response = await fetch(url.toString(), {
    headers: {
      'User-Agent': USER_AGENT,
      Accept: 'application/json',
    },
    timeout: 15000,
    redirect: 'follow',
  });

  if (response.status === 429) {
    const retryAfter = response.headers.get('retry-after') || '60';
    throw new Error(`Reddit rate limited. Retry after ${retryAfter}s`);
  }

  if (!response.ok) {
    throw new Error(`Reddit API returned ${response.status} for ${url.pathname}`);
  }

  return response.json();
}

/**
 * Check if snoowrap Reddit credentials are configured.
 * @returns {boolean}
 */
function hasCredentials() {
  return !!(
    process.env.REDDIT_CLIENT_ID &&
    process.env.REDDIT_CLIENT_SECRET &&
    process.env.REDDIT_USERNAME &&
    process.env.REDDIT_PASSWORD
  );
}

/**
 * Get an authenticated snoowrap client.
 * @returns {Object|null} snoowrap instance or null
 */
function getClient() {
  if (!hasCredentials()) {
    console.warn('[reddit] No credentials configured — write operations will simulate');
    return null;
  }

  try {
    const Snoowrap = require('snoowrap');
    return new Snoowrap({
      userAgent: USER_AGENT,
      clientId: process.env.REDDIT_CLIENT_ID,
      clientSecret: process.env.REDDIT_CLIENT_SECRET,
      username: process.env.REDDIT_USERNAME,
      password: process.env.REDDIT_PASSWORD,
    });
  } catch (err) {
    console.error('[reddit] Failed to create snoowrap client:', err.message);
    return null;
  }
}

/**
 * Search a subreddit for posts matching a query.
 *
 * @param {string} subreddit - Subreddit name (without r/)
 * @param {string} query - Search query
 * @param {string} [sort='new'] - Sort order: new, relevance, hot, top
 * @param {number} [limit=25] - Max results (1-100)
 * @returns {Promise<Array<{ id: string, title: string, selftext: string, score: number, num_comments: number, url: string, permalink: string, created_utc: number, author: string, subreddit: string }>>}
 */
async function searchSubreddit(subreddit, query, sort = 'new', limit = 25) {
  try {
    const data = await _redditGet(`/r/${subreddit}/search.json`, {
      q: query,
      sort,
      limit: Math.min(limit, 100),
      restrict_sr: 'on',
      type: 'link',
      t: 'week',
    });

    const children = data?.data?.children || [];
    return children.map((child) => {
      const d = child.data;
      return {
        id: d.id,
        title: d.title || '',
        selftext: (d.selftext || '').substring(0, 2000),
        score: d.score || 0,
        num_comments: d.num_comments || 0,
        url: d.url || '',
        permalink: `https://www.reddit.com${d.permalink}`,
        created_utc: d.created_utc || 0,
        author: d.author || '[deleted]',
        subreddit: d.subreddit || subreddit,
        upvote_ratio: d.upvote_ratio || 0,
        is_self: d.is_self || false,
      };
    });
  } catch (err) {
    console.error(`[reddit] searchSubreddit r/${subreddit} error:`, err.message);
    throw err;
  }
}

/**
 * Get hot posts from a subreddit.
 *
 * @param {string} subreddit - Subreddit name (without r/)
 * @param {number} [limit=25] - Max results (1-100)
 * @returns {Promise<Array>} Array of post objects (same shape as searchSubreddit)
 */
async function getHotPosts(subreddit, limit = 25) {
  try {
    const data = await _redditGet(`/r/${subreddit}/hot.json`, {
      limit: Math.min(limit, 100),
    });

    const children = data?.data?.children || [];
    return children
      .filter((child) => child.kind === 't3') // filter out stickied non-posts
      .map((child) => {
        const d = child.data;
        return {
          id: d.id,
          title: d.title || '',
          selftext: (d.selftext || '').substring(0, 2000),
          score: d.score || 0,
          num_comments: d.num_comments || 0,
          url: d.url || '',
          permalink: `https://www.reddit.com${d.permalink}`,
          created_utc: d.created_utc || 0,
          author: d.author || '[deleted]',
          subreddit: d.subreddit || subreddit,
          upvote_ratio: d.upvote_ratio || 0,
          is_self: d.is_self || false,
          stickied: d.stickied || false,
        };
      });
  } catch (err) {
    console.error(`[reddit] getHotPosts r/${subreddit} error:`, err.message);
    throw err;
  }
}

/**
 * Get new posts from a subreddit.
 *
 * @param {string} subreddit - Subreddit name (without r/)
 * @param {number} [limit=25] - Max results
 * @returns {Promise<Array>}
 */
async function getNewPosts(subreddit, limit = 25) {
  try {
    const data = await _redditGet(`/r/${subreddit}/new.json`, {
      limit: Math.min(limit, 100),
    });

    const children = data?.data?.children || [];
    return children
      .filter((child) => child.kind === 't3')
      .map((child) => {
        const d = child.data;
        return {
          id: d.id,
          title: d.title || '',
          selftext: (d.selftext || '').substring(0, 2000),
          score: d.score || 0,
          num_comments: d.num_comments || 0,
          url: d.url || '',
          permalink: `https://www.reddit.com${d.permalink}`,
          created_utc: d.created_utc || 0,
          author: d.author || '[deleted]',
          subreddit: d.subreddit || subreddit,
          upvote_ratio: d.upvote_ratio || 0,
          is_self: d.is_self || false,
        };
      });
  } catch (err) {
    console.error(`[reddit] getNewPosts r/${subreddit} error:`, err.message);
    throw err;
  }
}

/**
 * Get comments for a specific post.
 *
 * @param {string} postId - Reddit post ID (without t3_ prefix)
 * @returns {Promise<Array<{ id: string, body: string, score: number, author: string, created_utc: number }>>}
 */
async function getPostComments(postId) {
  try {
    const data = await _redditGet(`/comments/${postId}.json`, {
      sort: 'best',
      limit: 50,
    });

    // Reddit returns [post, comments] — we want the comments (index 1)
    const commentListing = data[1]?.data?.children || [];
    return commentListing
      .filter((child) => child.kind === 't1')
      .map((child) => {
        const d = child.data;
        return {
          id: d.id,
          body: (d.body || '').substring(0, 2000),
          score: d.score || 0,
          author: d.author || '[deleted]',
          created_utc: d.created_utc || 0,
          parent_id: d.parent_id || '',
          is_submitter: d.is_submitter || false,
        };
      });
  } catch (err) {
    console.error(`[reddit] getPostComments error for ${postId}:`, err.message);
    throw err;
  }
}

/**
 * Score the relevance of a Reddit post based on keyword matches.
 *
 * @param {Object} post - Post object with title and selftext
 * @returns {number} Relevance score 0-100
 */
function scoreRelevance(post) {
  const text = `${post.title || ''} ${post.selftext || ''}`.toLowerCase();
  let score = 0;
  let matchCount = 0;

  for (const keyword of KEYWORDS) {
    const kw = keyword.toLowerCase();
    if (text.includes(kw)) {
      matchCount++;
      // Weight by keyword importance
      if (['nonprofit', 'advocacy', 'civic engagement'].includes(kw)) {
        score += 15;
      } else if (['social justice', 'petition', 'policy change', 'civic action'].includes(kw)) {
        score += 12;
      } else if (['community organizing', 'grassroots', 'public interest'].includes(kw)) {
        score += 10;
      } else {
        score += 8;
      }
    }
  }

  // Bonus for engagement
  if (post.score > 100) score += 5;
  if (post.score > 500) score += 5;
  if (post.num_comments > 20) score += 5;
  if (post.num_comments > 100) score += 5;

  // Bonus for multiple keyword matches
  if (matchCount >= 3) score += 10;
  if (matchCount >= 5) score += 10;

  return Math.min(100, score);
}

/**
 * Submit a new post to a subreddit via snoowrap.
 *
 * @param {string} subreddit - Subreddit name (without r/)
 * @param {string} title - Post title
 * @param {string} text - Post body (self-text)
 * @returns {Promise<{ id: string|null, url: string|null, simulated: boolean, posted: boolean }>}
 */
async function submitPost(subreddit, title, text) {
  const client = getClient();
  if (!client) {
    return {
      id: null,
      url: null,
      simulated: true,
      posted: false,
      note: 'No Reddit credentials configured — post not submitted',
      draft: { subreddit, title, text },
    };
  }

  try {
    const submission = await client.getSubreddit(subreddit).submitSelfpost({
      title,
      text,
    });

    return {
      id: submission.name || submission.id,
      url: `https://www.reddit.com${submission.permalink || ''}`,
      simulated: false,
      posted: true,
    };
  } catch (err) {
    console.error(`[reddit] submitPost to r/${subreddit} error:`, err.message);
    return {
      id: null,
      url: null,
      simulated: false,
      posted: false,
      error: err.message,
    };
  }
}

/**
 * Reply to a Reddit post or comment via snoowrap.
 *
 * @param {string} thingId - Full thing ID (t3_xxx for post, t1_xxx for comment)
 * @param {string} text - Reply body text
 * @returns {Promise<{ id: string|null, simulated: boolean, posted: boolean }>}
 */
async function submitReply(thingId, text) {
  const client = getClient();
  if (!client) {
    return {
      id: null,
      simulated: true,
      posted: false,
      note: 'No Reddit credentials configured — reply not submitted',
      draft: { thingId, text },
    };
  }

  try {
    // Determine if it's a post (t3_) or comment (t1_)
    let thing;
    if (thingId.startsWith('t1_')) {
      thing = client.getComment(thingId);
    } else if (thingId.startsWith('t3_')) {
      thing = client.getSubmission(thingId);
    } else {
      // Assume it's a post ID without prefix
      thing = client.getSubmission(thingId);
    }

    const reply = await thing.reply(text);

    return {
      id: reply.name || reply.id,
      simulated: false,
      posted: true,
    };
  } catch (err) {
    console.error(`[reddit] submitReply to ${thingId} error:`, err.message);
    return {
      id: null,
      simulated: false,
      posted: false,
      error: err.message,
    };
  }
}

module.exports = {
  SUBREDDITS,
  KEYWORDS,
  hasCredentials,
  getClient,
  searchSubreddit,
  getHotPosts,
  getNewPosts,
  getPostComments,
  scoreRelevance,
  submitPost,
  submitReply,
};