← back to Norma

agents/reddit-agent/skills/post.js

187 lines

/**
 * Reddit Post Skill
 *
 * Submits self-text posts to advocacy subreddits.
 * Can auto-generate post content via AI if not provided.
 * Gracefully degrades to draft mode without credentials.
 *
 * Rate limit: 1 per 10min per subreddit
 */

const { RateLimiter } = require('../../shared/rate-limiter');
const { logAction } = require('../../shared/audit-logger');
const { generateContent } = require('../../shared/ai');
const { submitPost, hasCredentials, SUBREDDITS } = require('../lib/reddit');
const { query: dbQuery } = require('../../shared/db');

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

const rateLimiter = new RateLimiter(AGENT, PLATFORM);

/**
 * @param {Object} body - Request body
 * @param {string} body.subreddit - Target subreddit (without r/)
 * @param {string} [body.title] - Post title (auto-generated if not provided)
 * @param {string} [body.text] - Post body text (auto-generated if not provided)
 * @param {string} [body.petition_url] - Petition URL to include
 * @param {string} [body.petition_title] - Petition title for AI context
 * @param {string} [body.pipeline_id] - Pipeline entry ID to link
 * @returns {Promise<Object>}
 */
module.exports = async function post(body) {
  const subreddit = body.subreddit;
  if (!subreddit) {
    throw new Error('subreddit is required');
  }

  // Validate subreddit is in our target list (warn but don't block)
  if (!SUBREDDITS.includes(subreddit)) {
    console.warn(`[${AGENT}] Posting to non-standard subreddit: r/${subreddit}`);
  }

  // Check rate limit
  const limit = await rateLimiter.checkLimit('post');
  if (!limit.allowed) {
    await logAction({
      agent: AGENT,
      actionType: 'post',
      platform: PLATFORM,
      channel: `r/${subreddit}`,
      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,
    };
  }

  let title = body.title || '';
  let text = body.text || '';
  const petitionUrl = body.petition_url || '';
  const aiGenerated = !body.title || !body.text;

  // Auto-generate post content via AI if not provided
  if (!title || !text) {
    const petitionTitle = body.petition_title || 'civic advocacy';

    const prompt = `You are an authentic Reddit community member posting in r/${subreddit} about nonprofit advocacy. Generate a self-text post.

TOPIC: ${petitionTitle}
SUBREDDIT: r/${subreddit}
${petitionUrl ? `PETITION URL: ${petitionUrl}` : ''}

Respond ONLY with valid JSON (no markdown, no code fences):
{
  "title": "A compelling, genuine post title (max 300 chars, Reddit-style)",
  "text": "The full post body (2-4 paragraphs, conversational Reddit tone, max 2000 chars)"
}

Requirements:
- Title should sound like a real Reddit user, not a bot or marketer
- For r/nonprofit and r/activism: share perspectives on civic engagement, ask questions, or discuss impact
- For r/personalfinance: focus on practical financial impact of policy
- For r/politics and r/lostgeneration: focus on systemic issues and policy reform
- Body should be conversational, use Reddit formatting (bold, lists) sparingly
- If a petition URL is provided, mention it naturally — NOT as spam
- Do NOT use excessive emojis or exclamation marks
- Include a discussion question at the end to encourage engagement`;

    try {
      const response = await generateContent(prompt, {
        maxTokens: 1024,
        temperature: 0.8,
      });

      const cleaned = response.replace(/```json\s*/g, '').replace(/```\s*/g, '').trim();
      const parsed = JSON.parse(cleaned);

      title = title || parsed.title;
      text = text || parsed.text;
    } catch (err) {
      console.error(`[${AGENT}] AI generation failed:`, err.message);
      // Fallback template
      title = title || `Discussion: ${body.petition_title || 'Civic Advocacy'} — What are your thoughts?`;
      text = text || `I've been thinking a lot about ${body.petition_title || 'civic advocacy'} lately and wanted to get the community's perspective.\n\n${petitionUrl ? `Found this petition that relates to the issue: ${petitionUrl}\n\n` : ''}What has your experience been? Would love to hear different viewpoints.`;
    }
  }

  // Append petition URL if provided and not already in text
  if (petitionUrl && !text.includes(petitionUrl)) {
    text += `\n\n---\n\nRelated: ${petitionUrl}`;
  }

  // Enforce Reddit title limit (300 chars)
  if (title.length > 300) {
    title = title.substring(0, 297) + '...';
  }

  // Submit the post
  const result = await submitPost(subreddit, title, text);

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

  // Update pipeline if linked
  if (body.pipeline_id && result.id) {
    try {
      await dbQuery(
        `UPDATE petition_pipeline
         SET social_dispatched = social_dispatched || $1, updated_at = NOW()
         WHERE id = $2`,
        [
          JSON.stringify({
            reddit: {
              post_id: result.id,
              subreddit,
              posted_at: new Date().toISOString(),
            },
          }),
          body.pipeline_id,
        ]
      );
    } catch (err) {
      console.error(`[${AGENT}] Failed to update pipeline:`, err.message);
    }
  }

  // Log the action
  await logAction({
    agent: AGENT,
    actionType: 'post',
    platform: PLATFORM,
    targetId: result.id,
    targetUrl: result.url,
    content: `[${title}]\n\n${text}`,
    petitionId: body.pipeline_id || null,
    channel: `r/${subreddit}`,
    responseData: {
      post_id: result.id,
      subreddit,
      ai_generated: aiGenerated,
      simulated: result.simulated || false,
      title_length: title.length,
      text_length: text.length,
    },
    status: result.id || result.simulated ? 'success' : 'error',
    errorMessage: result.error || null,
  });

  return {
    post_id: result.id,
    subreddit,
    title,
    text,
    url: result.url,
    ai_generated: aiGenerated,
    simulated: result.simulated || false,
    posted: result.posted || false,
    credentials_configured: hasCredentials(),
    draft: result.draft || null,
  };
};