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