const api = require('./api'); const fetch = require('node-fetch'); // Kick API helpers (web scraping since no public API) let polling = false; const pollIntervalMs = Number(process.env.KICK_POLL_INTERVAL_MS || 15000); // 15s default (slower than Twitch) // Keep track of which streams we've already announced per guild:user -> { started_at } const announced = new Map(); // key: `${guildId}:${user}` -> { started_at } // Simple web scraping to check if a Kick user is live async function checkKickUserLive(username) { try { // First try the API endpoint const apiUrl = `https://kick.com/api/v1/channels/${encodeURIComponent(username)}`; const apiController = new AbortController(); const apiTimeoutId = setTimeout(() => apiController.abort(), 5000); const apiResponse = await fetch(apiUrl, { headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', 'Accept': 'application/json', 'Referer': 'https://kick.com/' }, signal: apiController.signal }); clearTimeout(apiTimeoutId); if (apiResponse.ok) { const data = await apiResponse.json(); if (data && data.livestream && data.livestream.is_live) { return { is_live: true, user_login: username, user_name: data.user?.username || username, title: data.livestream.session_title || `${username} is live`, viewer_count: data.livestream.viewer_count || 0, started_at: data.livestream.start_time, url: `https://kick.com/${username}`, thumbnail_url: data.livestream.thumbnail?.url || null, category: data.category?.name || 'Unknown', description: data.user?.bio || '' }; } return { is_live: false, user_login: username }; } // If API fails with 403, try web scraping as fallback if (apiResponse.status === 403) { console.log(`API blocked for ${username}, trying web scraping fallback...`); const pageUrl = `https://kick.com/${encodeURIComponent(username)}`; const pageController = new AbortController(); const pageTimeoutId = setTimeout(() => pageController.abort(), 5000); const pageResponse = await fetch(pageUrl, { headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', 'Accept-Language': 'en-US,en;q=0.5', 'Accept-Encoding': 'gzip, deflate, br', 'DNT': '1', 'Connection': 'keep-alive', 'Upgrade-Insecure-Requests': '1', 'Sec-Fetch-Dest': 'document', 'Sec-Fetch-Mode': 'navigate', 'Sec-Fetch-Site': 'none', 'Cache-Control': 'max-age=0' }, signal: pageController.signal }); clearTimeout(pageTimeoutId); if (pageResponse.ok) { const html = await pageResponse.text(); // Check for live stream indicators in the HTML const isLive = html.includes('"is_live":true') || html.includes('"is_live": true') || html.includes('data-is-live="true"') || html.includes('isLive:true'); if (isLive) { // Try to extract stream info from HTML let title = `${username} is live`; let viewerCount = 0; let category = 'Unknown'; // Extract title const titleMatch = html.match(/"session_title"\s*:\s*"([^"]+)"/) || html.match(/"title"\s*:\s*"([^"]+)"/); if (titleMatch) { title = titleMatch[1].replace(/\\"/g, '"'); } // Extract viewer count const viewerMatch = html.match(/"viewer_count"\s*:\s*(\d+)/); if (viewerMatch) { viewerCount = parseInt(viewerMatch[1]); } // Extract category const categoryMatch = html.match(/"category"\s*:\s*{\s*"name"\s*:\s*"([^"]+)"/); if (categoryMatch) { category = categoryMatch[1]; } return { is_live: true, user_login: username, user_name: username, title: title, viewer_count: viewerCount, started_at: new Date().toISOString(), url: `https://kick.com/${username}`, thumbnail_url: null, category: category, description: '' }; } } } return { is_live: false, user_login: username }; } catch (e) { if (e.name === 'AbortError') { console.error(`Timeout checking Kick user ${username}`); } else { console.error(`Failed to check Kick user ${username}:`, e && e.message ? e.message : e); } return { is_live: false, user_login: username }; } } // Check all Kick users for a guild async function checkKickStreamsForGuild(guildId, usernames) { const results = []; for (const username of usernames) { try { const stream = await checkKickUserLive(username); if (stream.is_live) { results.push(stream); } } catch (e) { console.error(`Error checking Kick user ${username}:`, e && e.message ? e.message : e); } // Small delay between requests to be respectful await new Promise(r => setTimeout(r, 500)); } return results; } async function checkGuild(client, guild) { try { // Get settings for this guild const settings = await api.getServerSettings(guild.id) || {}; const liveSettings = settings.liveNotifications || {}; if (!liveSettings.enabled) return; const channelId = liveSettings.channelId; const users = (liveSettings.kickUsers || []).map(u => u.toLowerCase()).filter(Boolean); if (!channelId || users.length === 0) return; // Check which users are live const live = await checkKickStreamsForGuild(guild.id, users); 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(`KickWatcher: channel ${channelId} is not a text channel (type: ${channel.type})`); channel = null; } } catch (e) { console.error(`KickWatcher: failed to fetch channel ${channelId}:`, e && e.message ? e.message : e); channel = null; } if (!channel) { 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 (similar to Twitch but with Kick branding) try { const { EmbedBuilder } = require('discord.js'); const embed = new EmbedBuilder() .setColor(0x53FC18) // Kick green color .setTitle(s.title || `${s.user_name} is live`) .setURL(s.url) .setAuthor({ name: s.user_name, iconURL: s.thumbnail_url || undefined, url: s.url }) .setThumbnail(s.thumbnail_url || undefined) .addFields( { name: 'Category', value: s.category || 'Unknown', inline: true }, { name: 'Viewers', value: String(s.viewer_count || 0), inline: true } ) .setDescription((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) let prefixMsg = ''; if (liveSettings.customMessage) { prefixMsg = liveSettings.customMessage; } else if (liveSettings.message) { prefixMsg = liveSettings.message; } else { prefixMsg = `🟢 ${s.user_name} is now live on Kick!`; } const payload = prefixMsg ? { content: prefixMsg, embeds: [embed] } : { embeds: [embed] }; await channel.send(payload); console.log(`🔔 Announced Kick live: ${login} - ${(s.title || '').slice(0, 80)}`); } catch (e) { console.error(`KickWatcher: failed to send announcement for ${login}:`, e && e.message ? e.message : e); // Fallback to simple message const msg = `🟢 ${s.user_name} is live on Kick: **${s.title}**\nWatch: ${s.url}`; try { await channel.send({ content: msg }); console.log('KickWatcher: fallback message sent'); } catch (err) { console.error('KickWatcher: fallback send failed:', err && err.message ? err.message : err); } } } } catch (e) { console.error('Error checking guild for Kick live streams:', e && e.message ? e.message : e); } } async function poll(client) { if (polling) return; polling = true; console.log(`🔁 KickWatcher 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('KickWatcher: initial checkGuild error', err && err.message ? err.message : err); }); } } catch (e) { console.error('Error during initial Kick 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('KickWatcher: checkGuild error', err && err.message ? err.message : err); }); } } catch (e) { console.error('Error during Kick poll loop:', e && e.message ? e.message : e); } await new Promise(r => setTimeout(r, pollIntervalMs)); } } function stop() { polling = false; } module.exports = { poll, stop };