← back to Norma

agents/twitch-agent/lib/twitch.js

574 lines

/**
 * Twitch API and TMI.js Wrapper
 *
 * Handles Twitch Helix API calls (search, streams, categories)
 * and TMI.js chat client (join, send, listen).
 * Falls back to simulation mode when credentials are not configured.
 */

const fetch = require('node-fetch');

// ──────────────────────────────────────
// Configuration
// ──────────────────────────────────────

const TWITCH_CLIENT_ID = process.env.TWITCH_CLIENT_ID || '';
const TWITCH_CLIENT_SECRET = process.env.TWITCH_CLIENT_SECRET || '';
const TWITCH_BOT_USERNAME = process.env.TWITCH_BOT_USERNAME || '';
const TWITCH_OAUTH_TOKEN = process.env.TWITCH_OAUTH_TOKEN || '';

const HELIX_BASE = 'https://api.twitch.tv/helix';
const OAUTH_URL = 'https://id.twitch.tv/oauth2/token';

// Keywords relevant to platform mission
const KEYWORDS = [
  'nonprofit',
  'advocacy',
  'civic engagement',
  'social justice',
  'financial literacy',
  'community organizing',
  'public interest',
  'policy change',
  'education funding',
  'economic justice',
  'consumer protection',
  'grassroots',
  'civic action',
  'public benefits',
];

// Twitch category IDs we care about
const MONITORED_CATEGORIES = {
  'Just Chatting': '509658',
  'Politics': '515467',
  'Science & Technology': '509670',
  'Talk Shows & Podcasts': '417752',
};

// Search queries for stream discovery
const SEARCH_QUERIES = [
  'financial literacy',
  'nonprofit advocacy',
  'education policy',
  'civic engagement',
  'community organizing',
  'personal finance',
];

// ──────────────────────────────────────
// App token management
// ──────────────────────────────────────

let appAccessToken = null;
let tokenExpiresAt = 0;

/**
 * Get or refresh Twitch app access token (Client Credentials flow).
 * @returns {Promise<string>} Bearer token
 */
async function getAppToken() {
  if (appAccessToken && Date.now() < tokenExpiresAt - 60000) {
    return appAccessToken;
  }

  if (!TWITCH_CLIENT_ID || !TWITCH_CLIENT_SECRET) {
    console.log('[twitch] No client credentials — running in simulation mode');
    return null;
  }

  try {
    const response = await fetch(OAUTH_URL, {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        client_id: TWITCH_CLIENT_ID,
        client_secret: TWITCH_CLIENT_SECRET,
        grant_type: 'client_credentials',
      }),
      timeout: 10000,
    });

    if (!response.ok) {
      const text = await response.text();
      throw new Error(`Token request failed (${response.status}): ${text}`);
    }

    const data = await response.json();
    appAccessToken = data.access_token;
    tokenExpiresAt = Date.now() + data.expires_in * 1000;

    console.log(`[twitch] App token acquired, expires in ${data.expires_in}s`);
    return appAccessToken;
  } catch (err) {
    console.error('[twitch] Failed to get app token:', err.message);
    return null;
  }
}

/**
 * Make an authenticated GET request to the Twitch Helix API.
 * @param {string} path - API path (e.g. '/search/channels')
 * @param {Object} params - Query parameters
 * @returns {Promise<Object>} Parsed JSON response
 */
async function helixGet(path, params = {}) {
  const token = await getAppToken();
  if (!token) {
    return _simulateHelixGet(path, params);
  }

  const url = new URL(`${HELIX_BASE}${path}`);
  for (const [key, val] of Object.entries(params)) {
    if (val !== undefined && val !== null) {
      url.searchParams.set(key, String(val));
    }
  }

  const response = await fetch(url.toString(), {
    headers: {
      'Client-ID': TWITCH_CLIENT_ID,
      'Authorization': `Bearer ${token}`,
    },
    timeout: 15000,
  });

  if (response.status === 401) {
    // Token expired, clear and retry once
    appAccessToken = null;
    tokenExpiresAt = 0;
    const newToken = await getAppToken();
    if (!newToken) return _simulateHelixGet(path, params);

    const retryResponse = await fetch(url.toString(), {
      headers: {
        'Client-ID': TWITCH_CLIENT_ID,
        'Authorization': `Bearer ${newToken}`,
      },
      timeout: 15000,
    });

    if (!retryResponse.ok) {
      throw new Error(`Helix API error (${retryResponse.status}) on retry: ${path}`);
    }
    return retryResponse.json();
  }

  if (!response.ok) {
    const text = await response.text();
    throw new Error(`Helix API error (${response.status}): ${text.substring(0, 200)}`);
  }

  return response.json();
}

// ──────────────────────────────────────
// TMI.js Chat Client
// ──────────────────────────────────────

let chatClient = null;
let isConnected = false;
let joinedChannels = new Set();
let recentMessages = []; // ring buffer of recent keyword-matching messages
const MAX_RECENT_MESSAGES = 200;

/**
 * Initialize the TMI.js chat client.
 * @returns {Promise<boolean>} true if connected, false if running in simulation
 */
async function initChat() {
  if (isConnected && chatClient) return true;

  if (!TWITCH_BOT_USERNAME || !TWITCH_OAUTH_TOKEN) {
    console.log('[twitch] No bot credentials — chat running in simulation mode');
    return false;
  }

  try {
    // Dynamic import — tmi.js is optional
    const tmi = require('tmi.js');

    chatClient = new tmi.Client({
      options: { debug: false },
      identity: {
        username: TWITCH_BOT_USERNAME,
        password: TWITCH_OAUTH_TOKEN,
      },
      channels: [],
    });

    // Listen for messages globally
    chatClient.on('message', (channel, userstate, message, self) => {
      if (self) return; // Ignore own messages
      _handleIncomingMessage(channel, userstate, message);
    });

    chatClient.on('connected', () => {
      console.log('[twitch] TMI.js connected to Twitch IRC');
      isConnected = true;
    });

    chatClient.on('disconnected', (reason) => {
      console.warn('[twitch] TMI.js disconnected:', reason);
      isConnected = false;
    });

    await chatClient.connect();
    return true;
  } catch (err) {
    console.error('[twitch] Failed to initialize chat:', err.message);
    return false;
  }
}

/**
 * Handle an incoming chat message — check against keywords.
 */
function _handleIncomingMessage(channel, userstate, message) {
  const lowerMsg = message.toLowerCase();
  const matchedKeywords = KEYWORDS.filter((kw) => lowerMsg.includes(kw.toLowerCase()));

  if (matchedKeywords.length > 0) {
    const entry = {
      channel: channel.replace('#', ''),
      username: userstate['display-name'] || userstate.username,
      message,
      keywords: matchedKeywords,
      timestamp: new Date().toISOString(),
      userId: userstate['user-id'],
    };

    recentMessages.push(entry);
    if (recentMessages.length > MAX_RECENT_MESSAGES) {
      recentMessages = recentMessages.slice(-MAX_RECENT_MESSAGES);
    }
  }
}

/**
 * Join a Twitch channel.
 * @param {string} channel - Channel name (without #)
 * @returns {Promise<boolean>}
 */
async function joinChannel(channel) {
  const clean = channel.replace('#', '').toLowerCase();

  if (joinedChannels.has(clean)) {
    return true;
  }

  if (!chatClient || !isConnected) {
    const connected = await initChat();
    if (!connected) {
      console.log(`[twitch] [sim] Would join channel: #${clean}`);
      joinedChannels.add(clean);
      return true;
    }
  }

  try {
    await chatClient.join(clean);
    joinedChannels.add(clean);
    console.log(`[twitch] Joined channel: #${clean}`);
    return true;
  } catch (err) {
    console.error(`[twitch] Failed to join #${clean}:`, err.message);
    return false;
  }
}

/**
 * Send a message to a Twitch channel.
 * @param {string} channel - Channel name
 * @param {string} message - Message text
 * @returns {Promise<{ sent: boolean, channel: string, message: string, simulated: boolean }>}
 */
async function sendMessage(channel, message) {
  const clean = channel.replace('#', '').toLowerCase();

  // Ensure we've joined the channel
  if (!joinedChannels.has(clean)) {
    await joinChannel(clean);
  }

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

  if (!chatClient || !isConnected) {
    console.log(`[twitch] [sim] #${clean}: ${truncated}`);
    return { sent: true, channel: clean, message: truncated, simulated: true };
  }

  try {
    await chatClient.say(clean, truncated);
    console.log(`[twitch] Sent to #${clean}: ${truncated.substring(0, 80)}...`);
    return { sent: true, channel: clean, message: truncated, simulated: false };
  } catch (err) {
    console.error(`[twitch] Failed to send to #${clean}:`, err.message);
    throw err;
  }
}

// ──────────────────────────────────────
// Helix API Functions
// ──────────────────────────────────────

/**
 * Search Twitch for channels/streams matching a query.
 * @param {string} queryText - Search query
 * @param {boolean} [liveOnly=true] - Only return live channels
 * @param {number} [maxResults=20] - Max results
 * @returns {Promise<Array>} Array of channel objects
 */
async function searchStreams(queryText, liveOnly = true, maxResults = 20) {
  const first = Math.min(maxResults, 100);

  const result = await helixGet('/search/channels', {
    query: queryText,
    live_only: liveOnly,
    first,
  });

  const channels = (result.data || []).map((ch) => ({
    broadcaster_login: ch.broadcaster_login,
    display_name: ch.display_name,
    broadcaster_id: ch.id,
    is_live: ch.is_live,
    game_name: ch.game_name || '',
    game_id: ch.game_id || '',
    title: ch.title || '',
    started_at: ch.started_at || null,
    language: ch.broadcaster_language || 'en',
    tags: ch.tags || [],
    relevance_score: scoreRelevance(ch),
  }));

  // Sort by relevance score descending
  channels.sort((a, b) => b.relevance_score - a.relevance_score);

  return channels;
}

/**
 * Get live streams for a specific game/category.
 * @param {string} categoryId - Twitch game/category ID
 * @param {number} [maxResults=50] - Max results
 * @returns {Promise<Array>} Array of stream objects
 */
async function getStreamsByCategory(categoryId, maxResults = 50) {
  const first = Math.min(maxResults, 100);

  const result = await helixGet('/streams', {
    game_id: categoryId,
    first,
  });

  return (result.data || []).map((stream) => ({
    stream_id: stream.id,
    user_login: stream.user_login,
    user_name: stream.user_name,
    user_id: stream.user_id,
    game_name: stream.game_name,
    game_id: stream.game_id,
    title: stream.title,
    viewer_count: stream.viewer_count,
    started_at: stream.started_at,
    language: stream.language,
    tags: stream.tags || [],
    is_mature: stream.is_mature,
    relevance_score: scoreRelevance(stream),
  }));
}

/**
 * Get information about specific users by login name.
 * @param {string[]} logins - Array of usernames
 * @returns {Promise<Array>}
 */
async function getUsers(logins) {
  if (!logins || logins.length === 0) return [];

  const params = {};
  // Helix accepts multiple login params
  const url = new URL(`${HELIX_BASE}/users`);
  for (const login of logins.slice(0, 100)) {
    url.searchParams.append('login', login);
  }

  const token = await getAppToken();
  if (!token) {
    return logins.map((l) => ({ login: l, simulated: true }));
  }

  const response = await fetch(url.toString(), {
    headers: {
      'Client-ID': TWITCH_CLIENT_ID,
      'Authorization': `Bearer ${token}`,
    },
    timeout: 15000,
  });

  if (!response.ok) return [];
  const data = await response.json();
  return data.data || [];
}

// ──────────────────────────────────────
// Relevance Scoring
// ──────────────────────────────────────

/**
 * Score a stream or channel for relevance to platform topics.
 * Higher score = more relevant.
 *
 * @param {Object} item - Stream or channel object from Helix API
 * @returns {number} Relevance score (0-100)
 */
function scoreRelevance(item) {
  let score = 0;
  const title = (item.title || '').toLowerCase();
  const gameName = (item.game_name || '').toLowerCase();
  const tags = (item.tags || []).map((t) => t.toLowerCase());

  // Keyword matches in title (highest value)
  for (const kw of KEYWORDS) {
    if (title.includes(kw.toLowerCase())) {
      score += 15;
    }
  }

  // Category match
  const monitoredCategoryNames = Object.keys(MONITORED_CATEGORIES).map((c) => c.toLowerCase());
  if (monitoredCategoryNames.some((cat) => gameName.includes(cat))) {
    score += 10;
  }

  // Tag matches
  const relevantTags = ['education', 'politics', 'finance', 'economics', 'news', 'debate'];
  for (const tag of tags) {
    if (relevantTags.includes(tag)) {
      score += 5;
    }
  }

  // Viewer count bonus (more viewers = more impact)
  const viewers = item.viewer_count || 0;
  if (viewers > 1000) score += 10;
  else if (viewers > 500) score += 7;
  else if (viewers > 100) score += 4;
  else if (viewers > 10) score += 2;

  // English language preference
  const lang = (item.language || item.broadcaster_language || '').toLowerCase();
  if (lang === 'en') score += 3;

  return Math.min(score, 100);
}

// ──────────────────────────────────────
// Simulation helpers (no credentials)
// ──────────────────────────────────────

function _simulateHelixGet(path, params) {
  console.log(`[twitch] [sim] Helix GET ${path} with params:`, params);

  if (path === '/search/channels') {
    return {
      data: _generateSimulatedChannels(params.query || 'finance', params.first || 5),
    };
  }

  if (path === '/streams') {
    return {
      data: _generateSimulatedStreams(params.game_id, params.first || 10),
    };
  }

  return { data: [] };
}

function _generateSimulatedChannels(queryText, count) {
  const channels = [];
  const topics = ['FinanceTalks', 'DebtFreeJourney', 'EduPolicy101', 'StudentVoices', 'CollegeCostsTruth'];

  for (let i = 0; i < Math.min(count, topics.length); i++) {
    channels.push({
      broadcaster_login: topics[i].toLowerCase(),
      display_name: topics[i],
      id: `sim_${100000 + i}`,
      is_live: Math.random() > 0.3,
      game_name: ['Just Chatting', 'Politics', 'Science & Technology'][i % 3],
      game_id: ['509658', '515467', '509670'][i % 3],
      title: `${queryText} discussion - live Q&A about advocacy and financial literacy`,
      started_at: new Date(Date.now() - Math.random() * 7200000).toISOString(),
      broadcaster_language: 'en',
      tags: ['Education', 'Finance'],
    });
  }

  return channels;
}

function _generateSimulatedStreams(gameId, count) {
  const streams = [];
  const names = ['PolicyNerd', 'FinLitCoach', 'DebtCrusher', 'EconExplainer', 'CivicTalk'];

  for (let i = 0; i < Math.min(count, names.length); i++) {
    streams.push({
      id: `sim_stream_${200000 + i}`,
      user_login: names[i].toLowerCase(),
      user_name: names[i],
      user_id: `sim_user_${300000 + i}`,
      game_name: 'Just Chatting',
      game_id: gameId || '509658',
      title: 'Discussing advocacy and education funding today',
      viewer_count: Math.floor(Math.random() * 2000),
      started_at: new Date(Date.now() - Math.random() * 10800000).toISOString(),
      language: 'en',
      tags: ['Education', 'Politics'],
      is_mature: false,
    });
  }

  return streams;
}

// ──────────────────────────────────────
// Getters
// ──────────────────────────────────────

function getRecentMessages() {
  return [...recentMessages];
}

function getJoinedChannels() {
  return [...joinedChannels];
}

function getChatStatus() {
  return {
    connected: isConnected,
    simulated: !chatClient || !isConnected,
    joinedChannels: [...joinedChannels],
    recentKeywordMessages: recentMessages.length,
    hasCredentials: !!(TWITCH_BOT_USERNAME && TWITCH_OAUTH_TOKEN),
    hasApiCredentials: !!(TWITCH_CLIENT_ID && TWITCH_CLIENT_SECRET),
  };
}

module.exports = {
  initChat,
  joinChannel,
  sendMessage,
  searchStreams,
  getStreamsByCategory,
  getUsers,
  scoreRelevance,
  getRecentMessages,
  getJoinedChannels,
  getChatStatus,
  KEYWORDS,
  MONITORED_CATEGORIES,
  SEARCH_QUERIES,
};