← back to Norma
agents/twitter-agent/skills/reply.js
137 lines
/**
* Twitter Reply Skill
*
* Generates and posts contextual replies to relevant Twitter threads.
* Uses AI to generate authentic, relevant responses.
*
* Rate limit: 15/day
*/
const { RateLimiter } = require('../../shared/rate-limiter');
const { logAction } = require('../../shared/audit-logger');
const { generateReply } = require('../../shared/ai');
const { postTweet, getTweetMetrics } = require('../lib/twitter');
const AGENT = 'twitter-agent';
const PLATFORM = 'twitter';
const rateLimiter = new RateLimiter(AGENT, PLATFORM);
/**
* @param {Object} body - Request body
* @param {string} body.tweet_id - Tweet ID to reply to
* @param {string} [body.context] - Context of the tweet being replied to (if not provided, will fetch it)
* @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.tweet_id) {
throw new Error('tweet_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.tweet_id,
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 tweet context if not provided
let context = body.context || '';
if (!context) {
try {
const tweetData = await getTweetMetrics(body.tweet_id);
context = tweetData.text || '';
} catch (err) {
console.warn(`[${AGENT}] Could not fetch tweet ${body.tweet_id}:`, err.message);
context = 'advocacy related discussion';
}
}
// Generate or use custom reply
let replyText;
let hashtags = [];
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: 'twitter',
});
replyText = generated.reply;
hashtags = generated.hashtags || [];
// Add hashtags if there's room
if (hashtags.length > 0) {
const hashtagStr = ' ' + hashtags.map((h) => (h.startsWith('#') ? h : `#${h}`)).join(' ');
if (replyText.length + hashtagStr.length <= 280) {
replyText += hashtagStr;
}
}
}
// Ensure reply is within 280 char limit
if (replyText.length > 280) {
replyText = replyText.substring(0, 277) + '...';
}
// Post the reply
const result = await postTweet(replyText, body.tweet_id);
// Record the rate limit
await rateLimiter.recordAction('reply');
// Log the action
await logAction({
agent: AGENT,
actionType: 'reply',
platform: PLATFORM,
targetId: body.tweet_id,
targetUrl: result.id
? `https://x.com/i/status/${result.id}`
: `https://x.com/i/status/${body.tweet_id}`,
content: replyText,
responseData: {
reply_tweet_id: result.id,
in_reply_to: body.tweet_id,
ai_generated: aiGenerated,
simulated: result.simulated || false,
hashtags,
char_count: replyText.length,
},
status: result.id || result.simulated ? 'success' : 'error',
errorMessage: result.error || null,
});
return {
reply_tweet_id: result.id,
in_reply_to: body.tweet_id,
text: replyText,
url: result.id ? `https://x.com/i/status/${result.id}` : null,
ai_generated: aiGenerated,
simulated: result.simulated || false,
hashtags,
char_count: replyText.length,
posted: result.posted || false,
};
};