← back to Norma
agents/reddit-agent/skills/reply.js
150 lines
/**
* Reddit Reply Skill
*
* Generates and posts contextual replies to Reddit posts and comments.
* Uses AI to generate authentic, relevant responses.
*
* Rate limit: 1 per 5min
*/
const { RateLimiter } = require('../../shared/rate-limiter');
const { logAction } = require('../../shared/audit-logger');
const { generateReply } = require('../../shared/ai');
const { submitReply, getPostComments, hasCredentials } = require('../lib/reddit');
const AGENT = 'reddit-agent';
const PLATFORM = 'reddit';
const rateLimiter = new RateLimiter(AGENT, PLATFORM);
/**
* @param {Object} body - Request body
* @param {string} body.post_id - Reddit post/comment ID to reply to
* @param {string} [body.context] - Context of the post being replied to
* @param {string} [body.subreddit] - Subreddit name (for AI context)
* @param {string} [body.petition_url] - Petition URL to mention if relevant
* @param {string} [body.custom_reply] - Custom reply text (skips AI generation)
* @returns {Promise<Object>}
*/
module.exports = async function reply(body) {
if (!body.post_id) {
throw new Error('post_id is required');
}
// Check rate limit
const limit = await rateLimiter.checkLimit('reply');
if (!limit.allowed) {
await logAction({
agent: AGENT,
actionType: 'reply',
platform: PLATFORM,
targetId: body.post_id,
channel: body.subreddit ? `r/${body.subreddit}` : null,
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 the post context if not provided
let context = body.context || '';
if (!context) {
try {
// Try to get the post text from its comments page
const postIdClean = body.post_id.replace(/^t3_/, '').replace(/^t1_/, '');
const comments = await getPostComments(postIdClean);
// Use the first few comments as context
context = comments
.slice(0, 3)
.map((c) => `u/${c.author}: ${c.body.substring(0, 200)}`)
.join('\n\n');
if (!context) {
context = 'Advocacy related discussion on Reddit';
}
} catch (err) {
console.warn(`[${AGENT}] Could not fetch context for ${body.post_id}:`, err.message);
context = 'Advocacy related discussion on Reddit';
}
}
// Add subreddit context if available
if (body.subreddit) {
context = `[r/${body.subreddit}]\n\n${context}`;
}
// Generate or use custom reply
let replyText;
let aiGenerated = false;
if (body.custom_reply) {
replyText = body.custom_reply;
} else {
aiGenerated = true;
const generated = await generateReply({
context,
petitionUrl: body.petition_url || null,
platform: 'reddit',
});
replyText = generated.reply;
}
// Ensure reply isn't too long (Reddit max is 10,000 chars, but keep it reasonable)
if (replyText.length > 3000) {
replyText = replyText.substring(0, 2997) + '...';
}
// Determine the full thing ID for Reddit API
let thingId = body.post_id;
if (!thingId.startsWith('t1_') && !thingId.startsWith('t3_')) {
// Default to post (t3_) if no prefix
thingId = `t3_${thingId}`;
}
// Submit the reply
const result = await submitReply(thingId, replyText);
// Record the rate limit
await rateLimiter.recordAction('reply');
// Log the action
await logAction({
agent: AGENT,
actionType: 'reply',
platform: PLATFORM,
targetId: body.post_id,
targetUrl: null,
content: replyText,
channel: body.subreddit ? `r/${body.subreddit}` : null,
responseData: {
reply_id: result.id,
in_reply_to: body.post_id,
ai_generated: aiGenerated,
simulated: result.simulated || false,
char_count: replyText.length,
},
status: result.id || result.simulated ? 'success' : 'error',
errorMessage: result.error || null,
});
return {
reply_id: result.id,
in_reply_to: body.post_id,
subreddit: body.subreddit || null,
text: replyText,
ai_generated: aiGenerated,
simulated: result.simulated || false,
posted: result.posted || false,
credentials_configured: hasCredentials(),
char_count: replyText.length,
draft: result.draft || null,
};
};