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 // 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) { try { // Intentionally quiet: per-guild checking logs are suppressed to avoid spam const settings = await api.getServerSettings(guild.id) || {}; const liveSettings = settings.liveNotifications || {}; if (!liveSettings.enabled) return; const channelId = liveSettings.channelId; const users = (liveSettings.users || []).map(u => u.toLowerCase()).filter(Boolean); if (!channelId || users.length === 0) return; // ask backend for current live streams const query = users.join(','); const streams = await api._rawGetTwitchStreams ? api._rawGetTwitchStreams(query) : null; // If the helper isn't available, try backend proxy let live = []; if (streams) live = streams.filter(s => s.is_live); else { try { const resp = await api.tryFetchTwitchStreams(query); live = (resp || []).filter(s => s.is_live); } catch (e) { live = []; } } 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 = `${guild.id}:${u}`; if (announced.has(key)) { announced.delete(key); } } return; } // fetch channel using client to ensure we can reach it let channel = null; try { channel = await client.channels.fetch(channelId); if (channel.type !== 0) { // 0 is text channel console.error(`TwitchWatcher: channel ${channelId} is not a text channel (type: ${channel.type})`); channel = null; } } catch (e) { console.error(`TwitchWatcher: failed to fetch channel ${channelId}:`, e && e.message ? e.message : e); channel = null; } if (!channel) { // Channel not found or inaccessible; skip 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 = `${guild.id}:${u}`; if (!liveLogins.has(u) && announced.has(key)) { announced.delete(key); } } // Announce each live once per live session for (const s of live) { const login = (s.user_login || '').toLowerCase(); const key = `${guild.id}:${login}`; if (announced.has(key)) continue; // already announced for this live session // mark announced for this session announced.set(key, { started_at: s.started_at || new Date().toISOString() }); // Build and send embed (standardized layout) try { // Announce without per-guild log spam 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); } catch (_) {} const embed = new EmbedBuilder() .setColor(0x9146FF) .setTitle(s.title || `${s.user_name} is live`) .setURL(s.url) .setAuthor({ name: s.user_name, iconURL: s.profile_image_url || undefined, url: s.url }) .setThumbnail(s.thumbnail_url || 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 } ) .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!`; } // 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(`🔔 Announced live: ${login} - ${(s.title || '').slice(0, 80)}`); } catch (e) { console.error(`TwitchWatcher: failed to send announcement for ${login}:`, e && e.message ? e.message : e); // fallback const msg = `🔴 ${s.user_name} is live: **${s.title}**\nWatch: ${s.url}`; try { await channel.send({ content: msg }); console.log('TwitchWatcher: fallback message sent'); } catch (err) { console.error('TwitchWatcher: fallback send failed:', err && err.message ? err.message : err); } } } } catch (e) { console.error('Error checking guild 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`); // Initial check on restart: send messages for currently live users try { const guilds = Array.from(client.guilds.cache.values()); for (const g of guilds) { await checkGuild(client, g).catch(err => { console.error('TwitchWatcher: initial checkGuild error', err && err.message ? err.message : err); }); } } catch (e) { console.error('Error during initial twitch check:', e && e.message ? e.message : e); } while (polling) { try { const guilds = Array.from(client.guilds.cache.values()); for (const g of guilds) { await checkGuild(client, g).catch(err => { console.error('TwitchWatcher: checkGuild error', err && err.message ? err.message : err); }); } } catch (e) { console.error('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 };