← 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,
};
};