294 lines
11 KiB
JavaScript
294 lines
11 KiB
JavaScript
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 }; |