diff --git a/aethex-bot/bot.js b/aethex-bot/bot.js index 143798c..94d7c9a 100644 --- a/aethex-bot/bot.js +++ b/aethex-bot/bot.js @@ -720,7 +720,7 @@ if (fs.existsSync(sentinelPath)) { // ============================================================================= const listenersPath = path.join(__dirname, "listeners"); -const generalListenerFiles = ['welcome.js', 'goodbye.js', 'xpTracker.js', 'reactionXp.js', 'voiceXp.js', 'starboard.js', 'federationProtection.js']; +const generalListenerFiles = ['welcome.js', 'goodbye.js', 'xpTracker.js', 'reactionXp.js', 'voiceXp.js', 'starboard.js', 'federationProtection.js', 'streamChecker.js']; for (const file of generalListenerFiles) { const filePath = path.join(listenersPath, file); if (fs.existsSync(filePath)) { diff --git a/aethex-bot/commands/streams.js b/aethex-bot/commands/streams.js new file mode 100644 index 0000000..12f690d --- /dev/null +++ b/aethex-bot/commands/streams.js @@ -0,0 +1,238 @@ +const { SlashCommandBuilder, EmbedBuilder, PermissionFlagsBits, ChannelType } = require('discord.js'); + +module.exports = { + data: new SlashCommandBuilder() + .setName('streams') + .setDescription('Twitch & YouTube live stream notifications') + .addSubcommand(sub => sub.setName('add').setDescription('Add a streamer to track') + .addStringOption(opt => opt.setName('platform').setDescription('Streaming platform') + .addChoices( + { name: 'Twitch', value: 'twitch' }, + { name: 'YouTube', value: 'youtube' } + ).setRequired(true)) + .addStringOption(opt => opt.setName('username').setDescription('Streamer username or channel ID').setRequired(true)) + .addChannelOption(opt => opt.setName('channel').setDescription('Channel to send notifications').setRequired(true) + .addChannelTypes(ChannelType.GuildText, ChannelType.GuildAnnouncement))) + .addSubcommand(sub => sub.setName('remove').setDescription('Remove a streamer from tracking') + .addStringOption(opt => opt.setName('username').setDescription('Streamer username').setRequired(true))) + .addSubcommand(sub => sub.setName('list').setDescription('List all tracked streamers')) + .addSubcommand(sub => sub.setName('message').setDescription('Customize the notification message') + .addStringOption(opt => opt.setName('username').setDescription('Streamer username').setRequired(true)) + .addStringOption(opt => opt.setName('message').setDescription('Custom message ({streamer}, {title}, {game}, {url})').setRequired(true).setMaxLength(500))) + .addSubcommand(sub => sub.setName('test').setDescription('Test notification for a streamer') + .addStringOption(opt => opt.setName('username').setDescription('Streamer username').setRequired(true))), + + async execute(interaction, supabase, client) { + if (!supabase) { + return interaction.reply({ content: 'Database not available.', ephemeral: true }); + } + + if (!interaction.member.permissions.has(PermissionFlagsBits.ManageGuild)) { + return interaction.reply({ content: 'You need Manage Server permission to manage stream notifications.', ephemeral: true }); + } + + const subcommand = interaction.options.getSubcommand(); + + if (subcommand === 'add') { + const platform = interaction.options.getString('platform'); + const username = interaction.options.getString('username').toLowerCase().trim(); + const channel = interaction.options.getChannel('channel'); + + const { data: existing } = await supabase + .from('stream_subscriptions') + .select('id') + .eq('guild_id', interaction.guildId) + .eq('username', username) + .eq('platform', platform) + .maybeSingle(); + + if (existing) { + return interaction.reply({ content: `${username} is already being tracked on ${platform}.`, ephemeral: true }); + } + + const { count } = await supabase + .from('stream_subscriptions') + .select('*', { count: 'exact', head: true }) + .eq('guild_id', interaction.guildId); + + if (count >= 25) { + return interaction.reply({ content: 'Maximum of 25 streamers per server reached.', ephemeral: true }); + } + + const { error } = await supabase.from('stream_subscriptions').insert({ + guild_id: interaction.guildId, + channel_id: channel.id, + platform, + username, + added_by: interaction.user.id, + }); + + if (error) { + console.error('Stream subscription error:', error); + return interaction.reply({ content: 'Failed to add streamer.', ephemeral: true }); + } + + const platformEmoji = platform === 'twitch' ? '<:twitch:🟣>' : '<:youtube:🔴>'; + + const embed = new EmbedBuilder() + .setColor(platform === 'twitch' ? 0x9146ff : 0xff0000) + .setTitle('Streamer Added!') + .setDescription(`Now tracking **${username}** on ${platform}`) + .addFields( + { name: 'Platform', value: platform.charAt(0).toUpperCase() + platform.slice(1), inline: true }, + { name: 'Notification Channel', value: `${channel}`, inline: true } + ) + .setFooter({ text: 'You will be notified when they go live!' }) + .setTimestamp(); + + await interaction.reply({ embeds: [embed] }); + } + + if (subcommand === 'remove') { + const username = interaction.options.getString('username').toLowerCase().trim(); + + const { data: sub } = await supabase + .from('stream_subscriptions') + .select('id, platform') + .eq('guild_id', interaction.guildId) + .eq('username', username) + .maybeSingle(); + + if (!sub) { + return interaction.reply({ content: `${username} is not being tracked.`, ephemeral: true }); + } + + const { error } = await supabase + .from('stream_subscriptions') + .delete() + .eq('id', sub.id); + + if (error) { + return interaction.reply({ content: 'Failed to remove streamer.', ephemeral: true }); + } + + const embed = new EmbedBuilder() + .setColor(0xff6600) + .setTitle('Streamer Removed') + .setDescription(`No longer tracking **${username}** on ${sub.platform}`) + .setTimestamp(); + + await interaction.reply({ embeds: [embed] }); + } + + if (subcommand === 'list') { + const { data: subs } = await supabase + .from('stream_subscriptions') + .select('*') + .eq('guild_id', interaction.guildId) + .order('created_at', { ascending: true }); + + if (!subs || subs.length === 0) { + const embed = new EmbedBuilder() + .setColor(0x7c3aed) + .setTitle('Stream Subscriptions') + .setDescription('No streamers are being tracked.\nUse `/streams add` to add one!') + .setTimestamp(); + return interaction.reply({ embeds: [embed] }); + } + + const platformEmojis = { twitch: '🟣', youtube: '🔴' }; + + const subList = subs.map((s, i) => { + const emoji = platformEmojis[s.platform] || '📺'; + const status = s.is_live ? '🔴 LIVE' : '⚫ Offline'; + return `${i + 1}. ${emoji} **${s.username}** (${s.platform})\n Channel: <#${s.channel_id}> | ${status}`; + }).join('\n\n'); + + const embed = new EmbedBuilder() + .setColor(0x7c3aed) + .setTitle('Stream Subscriptions') + .setDescription(subList) + .setFooter({ text: `${subs.length}/25 slots used` }) + .setTimestamp(); + + await interaction.reply({ embeds: [embed] }); + } + + if (subcommand === 'message') { + const username = interaction.options.getString('username').toLowerCase().trim(); + const customMessage = interaction.options.getString('message'); + + const { data: sub } = await supabase + .from('stream_subscriptions') + .select('id') + .eq('guild_id', interaction.guildId) + .eq('username', username) + .maybeSingle(); + + if (!sub) { + return interaction.reply({ content: `${username} is not being tracked.`, ephemeral: true }); + } + + const { error } = await supabase + .from('stream_subscriptions') + .update({ custom_message: customMessage, updated_at: new Date().toISOString() }) + .eq('id', sub.id); + + if (error) { + return interaction.reply({ content: 'Failed to update message.', ephemeral: true }); + } + + const embed = new EmbedBuilder() + .setColor(0x00ff00) + .setTitle('Custom Message Set') + .setDescription(`Notification message updated for **${username}**`) + .addFields({ name: 'New Message', value: customMessage }) + .setFooter({ text: 'Variables: {streamer}, {title}, {game}, {url}' }) + .setTimestamp(); + + await interaction.reply({ embeds: [embed] }); + } + + if (subcommand === 'test') { + const username = interaction.options.getString('username').toLowerCase().trim(); + + const { data: sub } = await supabase + .from('stream_subscriptions') + .select('*') + .eq('guild_id', interaction.guildId) + .eq('username', username) + .maybeSingle(); + + if (!sub) { + return interaction.reply({ content: `${username} is not being tracked.`, ephemeral: true }); + } + + const channel = interaction.guild.channels.cache.get(sub.channel_id); + if (!channel) { + return interaction.reply({ content: 'Notification channel not found.', ephemeral: true }); + } + + let message = sub.custom_message || '🔴 **{streamer}** is now live!\n{title}\n{url}'; + message = message + .replace('{streamer}', username) + .replace('{title}', 'Test Stream Title') + .replace('{game}', 'Just Chatting') + .replace('{url}', sub.platform === 'twitch' ? `https://twitch.tv/${username}` : `https://youtube.com/@${username}/live`); + + const embed = new EmbedBuilder() + .setColor(sub.platform === 'twitch' ? 0x9146ff : 0xff0000) + .setTitle(`${username} is now live!`) + .setDescription('Test Stream Title') + .addFields( + { name: 'Game/Category', value: 'Just Chatting', inline: true }, + { name: 'Platform', value: sub.platform.charAt(0).toUpperCase() + sub.platform.slice(1), inline: true } + ) + .setURL(sub.platform === 'twitch' ? `https://twitch.tv/${username}` : `https://youtube.com/@${username}/live`) + .setFooter({ text: 'TEST NOTIFICATION' }) + .setTimestamp(); + + try { + await channel.send({ content: message, embeds: [embed] }); + await interaction.reply({ content: `Test notification sent to ${channel}!`, ephemeral: true }); + } catch (err) { + await interaction.reply({ content: 'Failed to send test notification. Check bot permissions.', ephemeral: true }); + } + } + }, +}; diff --git a/aethex-bot/listeners/streamChecker.js b/aethex-bot/listeners/streamChecker.js new file mode 100644 index 0000000..ef4efdd --- /dev/null +++ b/aethex-bot/listeners/streamChecker.js @@ -0,0 +1,240 @@ +const { EmbedBuilder } = require('discord.js'); + +module.exports = { + name: 'ready', + once: true, + + async execute(client, supabase) { + if (!supabase) return; + + setInterval(async () => { + await checkStreams(client, supabase); + }, 5 * 60 * 1000); + + setTimeout(async () => { + await checkStreams(client, supabase); + }, 30000); + + console.log('[Streams] Stream checker initialized (5 min interval)'); + } +}; + +async function checkStreams(client, supabase) { + try { + const { data: subs } = await supabase + .from('stream_subscriptions') + .select('*'); + + if (!subs || subs.length === 0) return; + + const twitchSubs = subs.filter(s => s.platform === 'twitch'); + const youtubeSubs = subs.filter(s => s.platform === 'youtube'); + + if (twitchSubs.length > 0) { + await checkTwitchStreams(client, supabase, twitchSubs); + } + + if (youtubeSubs.length > 0) { + await checkYouTubeStreams(client, supabase, youtubeSubs); + } + } catch (err) { + console.error('[Streams] Error checking streams:', err.message); + } +} + +async function checkTwitchStreams(client, supabase, subs) { + const twitchClientId = process.env.TWITCH_CLIENT_ID; + const twitchClientSecret = process.env.TWITCH_CLIENT_SECRET; + + if (!twitchClientId || !twitchClientSecret) { + return; + } + + try { + const tokenRes = await fetch('https://id.twitch.tv/oauth2/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: `client_id=${twitchClientId}&client_secret=${twitchClientSecret}&grant_type=client_credentials` + }); + + if (!tokenRes.ok) return; + const { access_token } = await tokenRes.json(); + + const usernames = [...new Set(subs.map(s => s.username))]; + const chunkedUsernames = []; + for (let i = 0; i < usernames.length; i += 100) { + chunkedUsernames.push(usernames.slice(i, i + 100)); + } + + for (const chunk of chunkedUsernames) { + const userQuery = chunk.map(u => `user_login=${u}`).join('&'); + const streamsRes = await fetch(`https://api.twitch.tv/helix/streams?${userQuery}`, { + headers: { + 'Client-ID': twitchClientId, + 'Authorization': `Bearer ${access_token}` + } + }); + + if (!streamsRes.ok) continue; + const { data: liveStreams } = await streamsRes.json(); + + const liveUsernames = liveStreams?.map(s => s.user_login.toLowerCase()) || []; + + for (const sub of subs.filter(s => chunk.includes(s.username))) { + const isNowLive = liveUsernames.includes(sub.username.toLowerCase()); + const wasLive = sub.is_live; + + if (isNowLive && !wasLive) { + const streamData = liveStreams.find(s => s.user_login.toLowerCase() === sub.username.toLowerCase()); + await sendNotification(client, supabase, sub, streamData, 'twitch'); + + await supabase + .from('stream_subscriptions') + .update({ + is_live: true, + last_live_at: new Date().toISOString(), + last_notified_at: new Date().toISOString() + }) + .eq('id', sub.id); + } else if (!isNowLive && wasLive) { + await supabase + .from('stream_subscriptions') + .update({ is_live: false }) + .eq('id', sub.id); + } + } + } + } catch (err) { + console.error('[Streams] Twitch check error:', err.message); + } +} + +async function checkYouTubeStreams(client, supabase, subs) { + const youtubeApiKey = process.env.YOUTUBE_API_KEY; + + if (!youtubeApiKey) { + return; + } + + try { + for (const sub of subs) { + const searchRes = await fetch( + `https://www.googleapis.com/youtube/v3/search?part=snippet&channelId=${sub.username}&type=video&eventType=live&key=${youtubeApiKey}` + ); + + if (!searchRes.ok) { + const handleRes = await fetch( + `https://www.googleapis.com/youtube/v3/channels?part=snippet&forHandle=${sub.username}&key=${youtubeApiKey}` + ); + if (handleRes.ok) { + const { items } = await handleRes.json(); + if (items && items[0]) { + const channelId = items[0].id; + const liveRes = await fetch( + `https://www.googleapis.com/youtube/v3/search?part=snippet&channelId=${channelId}&type=video&eventType=live&key=${youtubeApiKey}` + ); + if (liveRes.ok) { + const { items: liveItems } = await liveRes.json(); + const isNowLive = liveItems && liveItems.length > 0; + const wasLive = sub.is_live; + + if (isNowLive && !wasLive) { + await sendNotification(client, supabase, sub, liveItems[0], 'youtube'); + await supabase + .from('stream_subscriptions') + .update({ + is_live: true, + last_live_at: new Date().toISOString(), + last_notified_at: new Date().toISOString() + }) + .eq('id', sub.id); + } else if (!isNowLive && wasLive) { + await supabase + .from('stream_subscriptions') + .update({ is_live: false }) + .eq('id', sub.id); + } + } + } + } + continue; + } + + const { items } = await searchRes.json(); + const isNowLive = items && items.length > 0; + const wasLive = sub.is_live; + + if (isNowLive && !wasLive) { + await sendNotification(client, supabase, sub, items[0], 'youtube'); + await supabase + .from('stream_subscriptions') + .update({ + is_live: true, + last_live_at: new Date().toISOString(), + last_notified_at: new Date().toISOString() + }) + .eq('id', sub.id); + } else if (!isNowLive && wasLive) { + await supabase + .from('stream_subscriptions') + .update({ is_live: false }) + .eq('id', sub.id); + } + + await new Promise(r => setTimeout(r, 1000)); + } + } catch (err) { + console.error('[Streams] YouTube check error:', err.message); + } +} + +async function sendNotification(client, supabase, sub, streamData, platform) { + try { + const guild = client.guilds.cache.get(sub.guild_id); + if (!guild) return; + + const channel = guild.channels.cache.get(sub.channel_id); + if (!channel) return; + + let title, game, url, thumbnail; + + if (platform === 'twitch') { + title = streamData.title || 'Live Stream'; + game = streamData.game_name || 'Unknown'; + url = `https://twitch.tv/${sub.username}`; + thumbnail = streamData.thumbnail_url?.replace('{width}', '400').replace('{height}', '225'); + } else { + title = streamData.snippet?.title || 'Live Stream'; + game = streamData.snippet?.channelTitle || sub.username; + url = `https://youtube.com/watch?v=${streamData.id?.videoId}`; + thumbnail = streamData.snippet?.thumbnails?.high?.url; + } + + let message = sub.custom_message || '🔴 **{streamer}** is now live!\n{title}\n{url}'; + message = message + .replace('{streamer}', sub.username) + .replace('{title}', title) + .replace('{game}', game) + .replace('{url}', url); + + const embed = new EmbedBuilder() + .setColor(platform === 'twitch' ? 0x9146ff : 0xff0000) + .setTitle(`${sub.username} is now live!`) + .setDescription(title) + .setURL(url) + .addFields( + { name: 'Game/Category', value: game, inline: true }, + { name: 'Platform', value: platform.charAt(0).toUpperCase() + platform.slice(1), inline: true } + ) + .setTimestamp(); + + if (thumbnail) { + embed.setImage(thumbnail); + } + + await channel.send({ content: message, embeds: [embed] }); + console.log(`[Streams] Sent notification for ${sub.username} going live on ${platform}`); + } catch (err) { + console.error('[Streams] Notification send error:', err.message); + } +}