← back to Norma

agents/twitch-agent/skills/discover.js

161 lines

/**
 * Twitch Discover Skill
 *
 * Searches Twitch for live streams related to financial literacy,
 * advocacy, and education policy. Uses Helix API search and
 * category-based stream browsing.
 *
 * Rate limit: 30/min (monitor action type)
 */

const { RateLimiter } = require('../../shared/rate-limiter');
const { logAction } = require('../../shared/audit-logger');
const { pushSocialFeed } = require('../../shared/pulse-reporter');
const {
  searchStreams,
  getStreamsByCategory,
  MONITORED_CATEGORIES,
  SEARCH_QUERIES,
} = require('../lib/twitch');

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

const rateLimiter = new RateLimiter(AGENT, PLATFORM);

/**
 * @param {Object} body - Request body
 * @param {string} [body.query] - Custom search query (uses random default if not provided)
 * @param {string} [body.category] - Category name to browse (e.g. 'Just Chatting')
 * @param {number} [body.max_results=20] - Max results per source
 * @param {boolean} [body.live_only=true] - Only return live channels
 * @returns {Promise<Object>}
 */
module.exports = async function discover(body) {
  // Check rate limit
  const limit = await rateLimiter.checkLimit('monitor');
  if (!limit.allowed) {
    await logAction({
      agent: AGENT,
      actionType: 'discover',
      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,
    };
  }

  const maxResults = Math.min(body.max_results || 20, 100);
  const liveOnly = body.live_only !== false;
  const allResults = [];

  // 1. Search by query
  const query = body.query || SEARCH_QUERIES[Math.floor(Math.random() * SEARCH_QUERIES.length)];

  try {
    const searchResults = await searchStreams(query, liveOnly, maxResults);
    for (const ch of searchResults) {
      allResults.push({
        source: 'search',
        query,
        ...ch,
      });
    }
    console.log(`[${AGENT}] Search for "${query}" found ${searchResults.length} channels`);
  } catch (err) {
    console.error(`[${AGENT}] Search error for "${query}":`, err.message);
  }

  // 2. Browse by category (if specified or as supplement)
  const categoriesToCheck = body.category
    ? { [body.category]: MONITORED_CATEGORIES[body.category] || body.category }
    : MONITORED_CATEGORIES;

  for (const [catName, catId] of Object.entries(categoriesToCheck)) {
    if (!catId) continue;

    try {
      const categoryStreams = await getStreamsByCategory(catId, Math.min(maxResults, 25));

      // Only include streams with some relevance to our topics
      const relevant = categoryStreams.filter((s) => s.relevance_score > 0);

      for (const stream of relevant) {
        // Avoid duplicates (same user across search + category)
        const exists = allResults.some(
          (r) => (r.broadcaster_login || r.user_login) === (stream.broadcaster_login || stream.user_login)
        );
        if (!exists) {
          allResults.push({
            source: 'category',
            category: catName,
            ...stream,
          });
        }
      }

      console.log(
        `[${AGENT}] Category "${catName}" found ${categoryStreams.length} streams, ${relevant.length} relevant`
      );
    } catch (err) {
      console.error(`[${AGENT}] Category browse error for "${catName}":`, err.message);
    }
  }

  // Sort all results by relevance score
  allResults.sort((a, b) => (b.relevance_score || 0) - (a.relevance_score || 0));

  // Push top discoveries to Pulse
  for (const item of allResults.slice(0, 5)) {
    const login = item.broadcaster_login || item.user_login;
    const name = item.display_name || item.user_name || login;
    const viewers = item.viewer_count || 0;

    await pushSocialFeed({
      agent: AGENT,
      platform: PLATFORM,
      type: 'discussion',
      title: `${name}: ${(item.title || '').substring(0, 100)}`,
      url: `https://twitch.tv/${login}`,
      content: `${item.game_name || 'Stream'} | ${viewers} viewers | Relevance: ${item.relevance_score}`,
      metadata: {
        broadcaster: login,
        viewers,
        game: item.game_name,
        relevance: item.relevance_score,
        source: item.source,
      },
    });
  }

  // Record the action
  await rateLimiter.recordAction('monitor');
  await logAction({
    agent: AGENT,
    actionType: 'discover',
    platform: PLATFORM,
    content: query,
    responseData: {
      query,
      totalFound: allResults.length,
      liveOnly,
      categoriesChecked: Object.keys(categoriesToCheck).length,
    },
    status: 'success',
  });

  return {
    query,
    count: allResults.length,
    streams: allResults.slice(0, maxResults),
    categories_checked: Object.keys(categoriesToCheck),
    fetched_at: new Date().toISOString(),
  };
};