← back to Norma

agents/discord-agent/skills/post.js

177 lines

/**
 * Discord Post Skill
 *
 * Posts messages and rich embeds to Discord channels.
 * Can auto-generate content via AI if not provided.
 * Supports rich embeds for petitions (title, description, URL, color, footer).
 *
 * Rate limit: 5 per 5sec per channel
 */

const { RateLimiter } = require('../../shared/rate-limiter');
const { logAction } = require('../../shared/audit-logger');
const { generateContent } = require('../../shared/ai');
const { sendMessage, sendEmbed, createPetitionEmbed, hasCredentials } = require('../lib/discord');
const { query: dbQuery } = require('../../shared/db');

const AGENT = 'discord-agent';
const PLATFORM = 'discord';

const rateLimiter = new RateLimiter(AGENT, PLATFORM);

/**
 * @param {Object} body - Request body
 * @param {string} body.channel_id - Target Discord channel ID
 * @param {string} [body.content] - Message text (auto-generated if not provided)
 * @param {Object} [body.embed] - Rich embed data (overrides plain text if provided)
 * @param {Object} [body.petition] - Petition data for auto-embed creation
 * @param {string} [body.petition_url] - Petition URL to include
 * @param {string} [body.petition_title] - Petition title for AI context
 * @param {string} [body.pipeline_id] - Pipeline entry ID to link
 * @returns {Promise<Object>}
 */
module.exports = async function post(body) {
  if (!body.channel_id) {
    throw new Error('channel_id is required');
  }

  // Check rate limit
  const limit = await rateLimiter.checkLimit('post');
  if (!limit.allowed) {
    await logAction({
      agent: AGENT,
      actionType: 'post',
      platform: PLATFORM,
      channel: body.channel_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,
    };
  }

  let result;
  let content = body.content || '';
  let embedData = body.embed || null;
  let aiGenerated = false;
  let useEmbed = false;

  // If petition data is provided, create a petition embed
  if (body.petition) {
    embedData = createPetitionEmbed(body.petition);
    useEmbed = true;
  }

  // Auto-generate content via AI if nothing provided
  if (!content && !embedData) {
    aiGenerated = true;
    const petitionTitle = body.petition_title || 'civic advocacy';

    const prompt = `You are a community organizer posting in a Discord server about nonprofit advocacy. Generate a message.

TOPIC: ${petitionTitle}
${body.petition_url ? `PETITION URL: ${body.petition_url}` : ''}

Requirements:
- Casual, community-oriented Discord tone
- 1-3 paragraphs, max 1500 characters
- Use Discord-style formatting: **bold**, *italic*, > quotes
- If a petition URL is provided, include it naturally
- Include a call to action (discuss, share, sign)
- Do NOT use excessive emojis

Respond with ONLY the message text, nothing else.`;

    try {
      content = await generateContent(prompt, {
        maxTokens: 512,
        temperature: 0.8,
      });
      content = content.trim().replace(/^["']|["']$/g, '');
    } catch (err) {
      console.error(`[${AGENT}] AI generation failed:`, err.message);
      content = `Hey everyone - wanted to share something about ${body.petition_title || 'civic advocacy'}.${body.petition_url ? `\n\nCheck it out: ${body.petition_url}` : ''}\n\nWhat are your thoughts?`;
    }
  }

  // Append petition URL if provided and not already in content
  if (body.petition_url && content && !content.includes(body.petition_url)) {
    content += `\n\n${body.petition_url}`;
  }

  // Send as embed or plain message
  if (useEmbed || embedData) {
    result = await sendEmbed(body.channel_id, embedData);
  } else {
    // Enforce Discord message limit (2000 chars)
    if (content.length > 2000) {
      content = content.substring(0, 1997) + '...';
    }
    result = await sendMessage(body.channel_id, content);
  }

  // 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({
            discord: {
              message_id: result.id,
              channel_id: body.channel_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,
    content: content || JSON.stringify(embedData),
    petitionId: body.pipeline_id || null,
    channel: body.channel_id,
    responseData: {
      message_id: result.id,
      channel_id: body.channel_id,
      ai_generated: aiGenerated,
      used_embed: useEmbed || !!embedData,
      simulated: result.simulated || false,
      char_count: content ? content.length : 0,
    },
    status: result.id || result.simulated ? 'success' : 'error',
    errorMessage: result.error || null,
  });

  return {
    message_id: result.id,
    channel_id: body.channel_id,
    content: useEmbed ? null : content,
    embed: useEmbed ? embedData : null,
    ai_generated: aiGenerated,
    simulated: result.simulated || false,
    posted: result.posted || false,
    credentials_configured: hasCredentials(),
    draft: result.draft || null,
  };
};