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