← back to Norma
agents/bluesky-agent/skills/post.js
155 lines
/**
* Bluesky Post Skill
*
* Posts petition content to Bluesky via AT Protocol.
* Runs in simulation mode without credentials.
*
* Rate limit: 10/day
*/
const { RateLimiter } = require('../../shared/rate-limiter');
const { logAction } = require('../../shared/audit-logger');
const { generateContent } = require('../../shared/ai');
const { createPost, hasCredentials } = require('../lib/bluesky');
const { query: dbQuery } = require('../../shared/db');
const AGENT = 'bluesky-agent';
const PLATFORM = 'bluesky';
const rateLimiter = new RateLimiter(AGENT, PLATFORM);
/**
* @param {Object} body - Request body
* @param {string} [body.text] - Post text (auto-generated if not provided)
* @param {string} [body.petition_url] - Petition URL to include
* @param {string} [body.petition_title] - Petition title for AI generation context
* @param {string} [body.title] - Alias for petition_title (from dispatch payload)
* @param {string} [body.pipeline_id] - Pipeline entry ID to link
* @param {string} [body.petition_id] - Alias for pipeline_id (from dispatch payload)
* @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 postText = body.text || '';
const petitionUrl = body.petition_url || '';
const aiGenerated = !body.text;
// Auto-generate post text via AI if not provided
if (!postText) {
const petitionTitle = body.petition_title || body.title || 'civic advocacy';
const prompt = `Generate a compelling Bluesky post about this petition/topic. Bluesky has a 300 character limit.
Topic: ${petitionTitle}
${petitionUrl ? `URL to include: ${petitionUrl}` : ''}
Requirements:
- Compelling, urgent advocacy tone
- Under 280 characters (leave room for URL)
- Include relevant hashtags like #CivicAction #Advocacy
- Call to action
- Do NOT include the URL — it will be appended separately
Respond with ONLY the post text, nothing else.`;
try {
postText = await generateContent(prompt, {
maxTokens: 128,
temperature: 0.8,
});
postText = postText.trim().replace(/^["']|["']$/g, '');
} catch (err) {
console.error(`[${AGENT}] AI generation failed:`, err.message);
postText = `Take action on ${body.petition_title || body.title || 'civic advocacy'}. Every voice matters. #CivicAction`;
}
}
// Append URL if there's room
if (petitionUrl && postText.length + petitionUrl.length + 2 <= 300) {
postText = `${postText}\n\n${petitionUrl}`;
}
// Enforce Bluesky 300 char limit
if (postText.length > 300) {
postText = postText.substring(0, 297) + '...';
}
// Post to Bluesky
const result = await createPost(postText);
// Record the rate limit
await rateLimiter.recordAction('post');
const pipelineId = body.pipeline_id || body.petition_id || null;
// Update pipeline if linked
if (pipelineId && (result.id || result.simulated)) {
try {
await dbQuery(
`UPDATE petition_pipeline
SET social_dispatched = COALESCE(social_dispatched, '{}'::jsonb) || $1, updated_at = NOW()
WHERE id = $2`,
[
JSON.stringify({
bluesky: {
post_id: result.id,
posted_at: new Date().toISOString(),
simulated: result.simulated,
},
}),
pipelineId,
]
);
} 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: postText,
petitionId: pipelineId,
responseData: {
post_id: result.id,
ai_generated: aiGenerated,
simulated: result.simulated,
char_count: postText.length,
},
status: result.id || result.simulated ? 'success' : 'error',
errorMessage: result.error || null,
});
return {
post_id: result.id,
text: postText,
url: result.url,
ai_generated: aiGenerated,
simulated: result.simulated,
posted: result.posted || false,
credentials_configured: hasCredentials(),
};
};