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