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