const api = require('./api'); const fetch = require('node-fetch'); // Twitch API credentials (optional). If provided, we'll enrich embeds with user bio. const twitchClientId = process.env.TWITCH_CLIENT_ID || null; const twitchClientSecret = process.env.TWITCH_CLIENT_SECRET || null; let twitchAppToken = null; // cached app access token let twitchTokenExpires = 0; // Cache of user login -> { description, profile_image_url, fetchedAt } const userInfoCache = new Map(); async function getAppToken() { if (!twitchClientId || !twitchClientSecret) return null; const now = Date.now(); if (twitchAppToken && now < twitchTokenExpires - 60000) { // refresh 1 min early return twitchAppToken; } try { const res = await fetch(`https://id.twitch.tv/oauth2/token?client_id=${twitchClientId}&client_secret=${twitchClientSecret}&grant_type=client_credentials`, { method: 'POST' }); const json = await res.json(); twitchAppToken = json.access_token; twitchTokenExpires = now + (json.expires_in * 1000); return twitchAppToken; } catch (e) { console.error('Failed to fetch Twitch app token:', e && e.message ? e.message : e); return null; } } async function fetchUserInfo(login) { if (!login) return null; const lower = login.toLowerCase(); const cached = userInfoCache.get(lower); const now = Date.now(); if (cached && now - cached.fetchedAt < 1000 * 60 * 30) { // 30 min cache return cached; } const token = await getAppToken(); if (!token) return null; try { const res = await fetch(`https://api.twitch.tv/helix/users?login=${encodeURIComponent(lower)}`, { headers: { 'Client-ID': twitchClientId, 'Authorization': `Bearer ${token}` } }); const json = await res.json(); const data = (json.data && json.data[0]) || null; if (data) { const info = { description: data.description || '', profile_image_url: data.profile_image_url || '', fetchedAt: now }; userInfoCache.set(lower, info); return info; } } catch (e) { console.error('Failed to fetch Twitch user info for', lower, e && e.message ? e.message : e); } return null; } let polling = false; const pollIntervalMs = Number(process.env.TWITCH_POLL_INTERVAL_MS || 5000); // 5s default const debugMode = false; // Debug logging disabled // Keep track of which streams we've already announced per guild:user -> { started_at } const announced = new Map(); // key: `${guildId}:${user}` -> { started_at } async function checkGuild(client, guild) { const guildId = guild.id; const guildName = guild.name; try { if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Checking guild ${guildName} (${guildId})`); const settings = await api.getServerSettings(guildId) || {}; const liveSettings = settings.liveNotifications || {}; if (debugMode) { console.log(`🔍 [DEBUG] TwitchWatcher: Guild ${guildName} settings:`, { enabled: liveSettings.enabled, channelId: liveSettings.channelId, usersCount: (liveSettings.users || []).length, hasCustomMessage: !!liveSettings.customMessage, hasDefaultMessage: !!liveSettings.message }); } if (!liveSettings.enabled) { if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Live notifications disabled for ${guildName}`); return; } const channelId = liveSettings.channelId; const users = (liveSettings.users || []).map(u => u.toLowerCase()).filter(Boolean); if (debugMode) { console.log(`🔍 [DEBUG] TwitchWatcher: Guild ${guildName} - Channel: ${channelId}, Users: [${users.join(', ')}]`); } if (!channelId || users.length === 0) { if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Skipping ${guildName} - ${!channelId ? 'No channel configured' : 'No users configured'}`); return; } // ask backend for current live streams const query = users.join(','); if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Fetching streams for query: ${query}`); const streams = await api._rawGetTwitchStreams ? api._rawGetTwitchStreams(query) : null; // If the helper isn't available, try backend proxy let live = []; if (streams && Array.isArray(streams)) { live = streams.filter(s => s.is_live); if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Found ${live.length} live streams via _rawGetTwitchStreams`); } else { if (debugMode && streams) { console.log(`🔍 [DEBUG] TwitchWatcher: _rawGetTwitchStreams returned non-array:`, typeof streams, streams); } try { const resp = await api.tryFetchTwitchStreams(query); live = (Array.isArray(resp) ? resp : []).filter(s => s.is_live); if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Found ${live.length} live streams via tryFetchTwitchStreams`); } catch (e) { console.error(`❌ TwitchWatcher: Failed to fetch streams for ${guildName}:`, e && e.message ? e.message : e); live = []; } } if (debugMode && live.length > 0) { console.log(`🔍 [DEBUG] TwitchWatcher: Live streams:`, live.map(s => `${s.user_login} (${s.viewer_count} viewers)`)); } if (!live || live.length === 0) { // No live streams: ensure any announced keys for these users are cleared so they can be re-announced later for (const u of users) { const key = `${guildId}:${u}`; if (announced.has(key)) { announced.delete(key); if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Cleared announcement for ${u} in ${guildName} (no longer live)`); } } if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: No live streams found for ${guildName}`); return; } // fetch channel using client to ensure we can reach it let channel = null; try { channel = await client.channels.fetch(channelId); if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Successfully fetched channel ${channel.name} (${channelId}) in ${guildName}`); if (channel.type !== 0) { // 0 is text channel console.error(`❌ TwitchWatcher: Channel ${channelId} in ${guildName} is not a text channel (type: ${channel.type})`); channel = null; } else { // Check if bot has permission to send messages const permissions = channel.permissionsFor(client.user); if (!permissions || !permissions.has('SendMessages')) { console.error(`❌ TwitchWatcher: Bot lacks SendMessages permission in channel ${channel.name} (${channelId}) for ${guildName}`); channel = null; } else if (debugMode) { console.log(`🔍 [DEBUG] TwitchWatcher: Bot has SendMessages permission in ${channel.name}`); } } } catch (e) { console.error(`❌ TwitchWatcher: Failed to fetch channel ${channelId} for ${guildName}:`, e && e.message ? e.message : e); channel = null; } if (!channel) { // Channel not found or inaccessible; skip if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Skipping announcements for ${guildName} - channel unavailable`); return; } // Build a map of live logins for quick lookup const liveLogins = new Set(live.map(s => (s.user_login || '').toLowerCase())); // Clear announced entries for users that are no longer live for (const u of users) { const key = `${guildId}:${u}`; if (!liveLogins.has(u) && announced.has(key)) { announced.delete(key); if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Cleared announcement for ${u} in ${guildName} (stream ended)`); } } // Announce each live once per live session for (const s of live) { const login = (s.user_login || '').toLowerCase(); const key = `${guildId}:${login}`; if (announced.has(key)) { if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Skipping ${login} in ${guildName} - already announced`); continue; // already announced for this live session } if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Preparing announcement for ${login} in ${guildName}`); // mark announced for this session announced.set(key, { started_at: s.started_at || new Date().toISOString() }); // Build and send embed (standardized layout) try { const { EmbedBuilder } = require('discord.js'); // Attempt to enrich with user bio (description) if available let bio = ''; try { const info = await fetchUserInfo(login); if (info && info.description) bio = info.description.slice(0, 200); if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Fetched user info for ${login} - bio length: ${bio.length}`); } catch (e) { if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Failed to fetch user info for ${login}:`, e && e.message ? e.message : e); } const embed = new EmbedBuilder() .setColor('#6441A5') // Twitch purple .setAuthor({ name: s.user_name, iconURL: s.profile_image_url || undefined, url: s.url }) .setTitle(s.title || `${s.user_name} is live`) .setURL(s.url) .setThumbnail(s.profile_image_url || undefined) .addFields( { name: 'Category', value: s.game_name || 'Unknown', inline: true }, { name: 'Viewers', value: String(s.viewer_count || 0), inline: true } ) .setImage(s.thumbnail_url ? s.thumbnail_url.replace('{width}', '640').replace('{height}', '360') + `?t=${Date.now()}` : null) .setDescription(bio || (s.description || '').slice(0, 200)) .setFooter({ text: `ehchadservices • Started: ${s.started_at ? new Date(s.started_at).toLocaleString() : 'unknown'}` }); // Determine message text (custom overrides default). Provide a plain text prefix if available. let prefixMsg = ''; if (liveSettings.customMessage) { prefixMsg = liveSettings.customMessage; } else if (liveSettings.message) { prefixMsg = liveSettings.message; } else { prefixMsg = `🔴 ${s.user_name} is now live!`; } // Replace template variables in custom messages prefixMsg = prefixMsg .replace(/\{user\}/g, s.user_name || login) .replace(/\{title\}/g, s.title || 'Untitled Stream') .replace(/\{category\}/g, s.game_name || 'Unknown') .replace(/\{viewers\}/g, String(s.viewer_count || 0)); if (debugMode) { console.log(`🔍 [DEBUG] TwitchWatcher: Sending announcement for ${login} in ${guildName} to #${channel.name}`); console.log(`🔍 [DEBUG] TwitchWatcher: Message content: "${prefixMsg}"`); } // Ensure we always hyperlink the title via embed; prefix is optional add above embed const payload = prefixMsg ? { content: prefixMsg, embeds: [embed] } : { embeds: [embed] }; await channel.send(payload); console.log(`🔔 TwitchWatcher: Successfully announced ${login} in ${guildName} - "${(s.title || '').slice(0, 80)}"`); } catch (e) { console.error(`❌ TwitchWatcher: Failed to send announcement for ${login} in ${guildName}:`, e && e.message ? e.message : e); // fallback const msg = `🔴 ${s.user_name} is live: **${s.title}**\nWatch: ${s.url}`; try { if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Attempting fallback message for ${login} in ${guildName}`); await channel.send({ content: msg }); console.log(`🔔 TwitchWatcher: Fallback message sent for ${login} in ${guildName}`); } catch (err) { console.error(`❌ TwitchWatcher: Fallback send failed for ${login} in ${guildName}:`, err && err.message ? err.message : err); } } } } catch (e) { console.error(`❌ TwitchWatcher: Error checking guild ${guildName} (${guildId}) for live streams:`, e && e.message ? e.message : e); } } async function poll(client) { if (polling) return; polling = true; console.log(`🔁 TwitchWatcher started, polling every ${Math.round(pollIntervalMs/1000)}s${debugMode ? ' (DEBUG MODE ENABLED)' : ''}`); // Initial check on restart: send messages for currently live users try { const guilds = Array.from(client.guilds.cache.values()); if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Initial check for ${guilds.length} guilds`); for (const g of guilds) { if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Initial check for guild ${g.name} (${g.id})`); await checkGuild(client, g).catch(err => { console.error(`❌ TwitchWatcher: Initial checkGuild error for ${g.name}:`, err && err.message ? err.message : err); }); } } catch (e) { console.error('❌ TwitchWatcher: Error during initial twitch check:', e && e.message ? e.message : e); } while (polling) { try { const guilds = Array.from(client.guilds.cache.values()); if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Polling cycle starting for ${guilds.length} guilds`); for (const g of guilds) { await checkGuild(client, g).catch(err => { console.error(`❌ TwitchWatcher: checkGuild error for ${g.name}:`, err && err.message ? err.message : err); }); } if (debugMode) console.log(`🔍 [DEBUG] TwitchWatcher: Polling cycle completed, waiting ${Math.round(pollIntervalMs/1000)}s`); } catch (e) { console.error('❌ TwitchWatcher: Error during twitch poll loop:', e && e.message ? e.message : e); } await new Promise(r => setTimeout(r, pollIntervalMs)); } } function stop() { polling = false; } module.exports = { poll, stop };