← back to Norma

agents/twitch-agent/skills/reply.js

119 lines

/**
 * Twitch Reply Skill
 *
 * Generates a contextual AI reply to a Twitch chat message.
 * Mentions @username and keeps it concise for chat format.
 *
 * Rate limit: 20/30sec (shared with post)
 */

const { RateLimiter } = require('../../shared/rate-limiter');
const { logAction } = require('../../shared/audit-logger');
const { generateReply } = require('../../shared/ai');
const { sendMessage, initChat } = require('../lib/twitch');

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

const rateLimiter = new RateLimiter(AGENT, PLATFORM);

/**
 * @param {Object} body - Request body
 * @param {string} body.channel - Twitch channel name (required)
 * @param {string} body.username - Username to reply to (required)
 * @param {string} body.message - The message being replied to (required)
 * @param {string} [body.context] - Additional context about the conversation
 * @param {string} [body.petition_url] - Optional petition URL if naturally relevant
 * @returns {Promise<Object>}
 */
module.exports = async function reply(body) {
  const { channel, username, message } = body;

  if (!channel) throw new Error('channel is required');
  if (!username) throw new Error('username is required');
  if (!message) throw new Error('message is required');

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

  // Initialize chat client
  await initChat();

  // Build context for AI
  const fullContext = body.context
    ? `Channel: #${channel}\n${body.context}\n\n@${username} said: "${message}"`
    : `Twitch chat in #${channel}.\n@${username} said: "${message}"`;

  // Generate AI reply
  let replyText;
  try {
    const aiResult = await generateReply({
      context: fullContext,
      petitionUrl: body.petition_url || null,
      platform: 'twitch',
    });

    replyText = aiResult.reply;
  } catch (err) {
    console.error(`[${AGENT}] AI reply generation failed:`, err.message);
    // Fallback to a generic empathetic response
    replyText = "That's a really good point. These advocacy issues affect so many people.";
  }

  // Prepend @username mention
  const fullReply = `@${username} ${replyText}`;

  // Truncate to Twitch's limit
  const truncated = fullReply.substring(0, 500);

  // Send the reply
  const result = await sendMessage(channel, truncated);

  // Record the action
  await rateLimiter.recordAction('post');
  await logAction({
    agent: AGENT,
    actionType: 'reply',
    platform: PLATFORM,
    channel,
    content: truncated,
    targetId: username,
    targetUrl: `https://twitch.tv/${channel.replace('#', '')}`,
    responseData: {
      replyTo: username,
      originalMessage: message.substring(0, 200),
      sent: result.sent,
      simulated: result.simulated,
      replyLength: truncated.length,
    },
    status: 'success',
  });

  return {
    channel: result.channel,
    reply_to: username,
    original_message: message,
    reply: truncated,
    sent: result.sent,
    simulated: result.simulated,
    replied_at: new Date().toISOString(),
  };
};