← back to Norma
agents/twitter-agent/skills/post.js
166 lines
/**
* Twitter Post Skill
*
* Posts tweets with petition content and links.
* Can auto-generate tweet text via AI if not provided (max 280 chars).
*
* Rate limit: 8/day
*/
const { RateLimiter } = require('../../shared/rate-limiter');
const { logAction } = require('../../shared/audit-logger');
const { generateContent } = require('../../shared/ai');
const { postTweet, ADVOCACY_HASHTAGS } = require('../lib/twitter');
const { query: dbQuery } = require('../../shared/db');
const AGENT = 'twitter-agent';
const PLATFORM = 'twitter';
const rateLimiter = new RateLimiter(AGENT, PLATFORM);
/**
* @param {Object} body - Request body
* @param {string} [body.text] - Tweet text (auto-generated if not provided)
* @param {string} [body.petition_url] - URL to include in the tweet
* @param {string} [body.petition_title] - Petition title for AI generation context
* @param {string} [body.pipeline_id] - Pipeline entry ID to link
* @param {string[]} [body.hashtags] - Specific hashtags to include
* @returns {Promise<Object>}
*/
module.exports = async function post(body) {
// Check rate limit
const limit = await rateLimiter.checkLimit('post');
if (!limit.allowed) {
await logAction({
agent: AGENT,
actionType: 'post',
platform: PLATFORM,
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 tweetText = body.text || '';
const petitionUrl = body.petition_url || '';
const aiGenerated = !body.text;
// Auto-generate tweet text via AI if not provided
if (!tweetText) {
const petitionTitle = body.petition_title || 'civic advocacy';
const hashtags = body.hashtags || ADVOCACY_HASHTAGS.slice(0, 2);
const prompt = `Generate a compelling tweet about this petition/topic. The tweet must be under 250 characters (leave room for a URL).
Topic: ${petitionTitle}
${petitionUrl ? `URL to include: ${petitionUrl}` : ''}
Suggested hashtags: ${hashtags.join(' ')}
Requirements:
- Urgent, compelling tone
- Include 1-2 relevant hashtags
- Call to action (sign, share, learn more)
- Must be under 250 characters total (excluding URL)
- Do NOT include the URL in your response — it will be appended separately
Respond with ONLY the tweet text, nothing else.`;
try {
tweetText = await generateContent(prompt, {
maxTokens: 128,
temperature: 0.8,
});
// Clean up the response
tweetText = tweetText.trim().replace(/^["']|["']$/g, '');
} catch (err) {
console.error(`[${AGENT}] AI generation failed:`, err.message);
// Fallback to a simple template
tweetText = `Take action on ${body.petition_title || 'civic advocacy'}. Every signature counts. ${hashtags[0] || '#CivicAction'}`;
}
}
// Append petition URL if provided and there's room
if (petitionUrl) {
// Twitter shortens URLs to ~23 chars via t.co
const urlLength = 23;
if (tweetText.length + 1 + urlLength <= 280) {
tweetText = `${tweetText}\n\n${petitionUrl}`;
} else {
// Truncate text to make room for URL
const maxTextLength = 280 - urlLength - 4; // 4 for newlines and buffer
tweetText = tweetText.substring(0, maxTextLength) + `\n\n${petitionUrl}`;
}
}
// Ensure tweet is within 280 char limit
if (tweetText.length > 280) {
tweetText = tweetText.substring(0, 277) + '...';
}
// Post the tweet
const result = await postTweet(tweetText);
// 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({
twitter: {
tweet_id: result.id,
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.id
? `https://x.com/i/status/${result.id}`
: null,
content: tweetText,
petitionId: body.pipeline_id || null,
responseData: {
tweet_id: result.id,
ai_generated: aiGenerated,
simulated: result.simulated || false,
char_count: tweetText.length,
},
status: result.id || result.simulated ? 'success' : 'error',
errorMessage: result.error || null,
});
return {
tweet_id: result.id,
text: tweetText,
url: result.id ? `https://x.com/i/status/${result.id}` : null,
ai_generated: aiGenerated,
simulated: result.simulated || false,
char_count: tweetText.length,
posted: result.posted || false,
};
};