diff --git a/.env.example b/.env.example index 565016f..3147b2e 100644 --- a/.env.example +++ b/.env.example @@ -16,5 +16,9 @@ MYSQL_USER='YOUR_MYSQL_USER' MYSQL_PASSWORD='YOUR_MYSQL_PASSWORD' MYSQL_DATABASE='YOUR_DATABASE_NAME' +# Twitch +TWITCH_CLIENT_ID='YOUR_TWITCH_CLIENT_ID' +TWITCH_CLIENT_SECRET='YOUR_TWITCH_CLIENT_SECRET' + # Configuration CONFIG_UPDATE_INTERVAL='60' diff --git a/README.md b/README.md index 6524aa5..c9bffd0 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # Feedr The next best Discord Bot to notify your members about YouTube video uploads! -Feedr checks for new uploads every **15** seconds +Feedr checks for: +* YouTube uploads every **10** seconds +* Twitch streams are live every **2** seconds Invite the bot [here](https://discord.com/oauth2/authorize?client_id=1243939861996503171&permissions=274877959232&integration_type=0&scope=applications.commands+bot) @@ -29,6 +31,10 @@ Feedr strives for constant improvement, so here's what will be implemented * Add Reactions # Changelog +## 1.2.0 +* Added Twitch feed +* `platform` added to both **/track** and **/untrack** + ## 1.1.0 * Replies are no longer deferred * Messages can now be sent in Announcement channels [1.0.3] diff --git a/package-lock.json b/package-lock.json index cd1ebe2..a5a9129 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "videonotifier", - "version": "1.1.0", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "videonotifier", - "version": "1.1.0", + "version": "1.2.0", "dependencies": { "discord-api-types": "^0.37.93", "discord.js": "^14.15.3", diff --git a/package.json b/package.json index 6151a77..8590597 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "videonotifier", "module": "src/index.ts", "type": "module", - "version": "1.1.0", + "version": "1.2.0", "devDependencies": { "@types/bun": "1.1.6" }, diff --git a/src/commands.ts b/src/commands.ts index 0af7120..3b9fa63 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -1,10 +1,12 @@ import { heapStats } from 'bun:jsc'; import client from '.'; import { ChannelType, GuildMember, type CommandInteraction } from 'discord.js'; -import checkIfChannelIdIsValid from './utils/checkIfChannelIdIsValid'; -import { addNewChannelToTrack, addNewGuildToTrackChannel, checkIfChannelIsAlreadyTracked, checkIfGuildIsTrackingChannelAlready, stopGuildTrackingChannel } from './database'; -import getChannelDetails from './utils/getChannelDetails'; +import checkIfChannelIdIsValid from './utils/youtube/checkIfChannelIdIsValid'; +import { addNewChannelToTrack, addNewGuildToTrackChannel, checkIfChannelIsAlreadyTracked, checkIfGuildIsTrackingChannelAlready, stopGuildTrackingChannel, twitchAddNewChannelToTrack, twitchAddNewGuildToTrackChannel, twitchCheckIfChannelIsAlreadyTracked, twitchCheckIfGuildIsTrackingChannelAlready, twitchStopGuildTrackingChannel } from './database'; +import getChannelDetails from './utils/youtube/getChannelDetails'; import { PermissionFlagsBits } from 'discord-api-types/v8'; +import { getStreamerId } from './utils/twitch/getStreamerId'; +import { checkIfStreamerIsLive } from './utils/twitch/checkIfStreamerIsLive'; interface Command { data: { @@ -121,22 +123,39 @@ const commands: Record = { }, track: { data: { - options: [{ - name: 'youtube_channel', - description: 'Enter the YouTube channel ID to track', - type: 3, - required: true, - }, { - name: 'updates_channel', - description: 'Enter the Guild channel to recieve updates in.', - type: 7, - required: true, - }, { - name: 'role', - description: 'Enter the role to mention (optional)', - type: 8, - required: false, - }], + options: [ + { + name: 'platform', + description: 'Select a supported platform to track', + type: 3, + required: true, + choices: [ + { + name: 'Twitch', + value: 'twitch', + }, + { + name: 'YouTube', + value: 'youtube', + }, + ] + }, + { + name: 'user_id', + description: 'Enter the YouTube channel ID or Twitch Streamer to track', + type: 3, + required: true, + }, { + name: 'updates_channel', + description: 'Enter the Guild channel to recieve updates in.', + type: 7, + required: true, + }, { + name: 'role', + description: 'Enter the role to mention (optional)', + type: 8, + required: false, + }], name: 'track', description: 'Track a channel to get notified when they upload a video!', integration_types: [0, 1], @@ -144,31 +163,32 @@ const commands: Record = { }, execute: async (interaction: CommandInteraction) => { // Get the YouTube Channel ID - const youtubeChannelId = interaction.options.get('youtube_channel')?.value as string; + const targetPlatform = interaction.options.get('platform')?.value as string; + const platformUserId = interaction.options.get('user_id')?.value as string; const discordChannelId = interaction.options.get('updates_channel')?.value as string; const guildId = interaction.guildId; - // Check that the channel ID is in a valid format - if (youtubeChannelId.length != 24 || !youtubeChannelId.startsWith('UC')) { + // Checks if the platform is valid ig + if (targetPlatform != 'youtube' && targetPlatform != 'twitch') { await interaction.reply({ ephemeral: true, - content: 'Invalid YouTube channel ID format!', + content: 'Platform not supported! Please select a platform to track!', }); return; } // DMs are currently not supported, so throw back an error if (!guildId || interaction.channel?.isDMBased()) { - await interaction.followUp({ + await interaction.reply({ ephemeral: true, - content: 'This command is not supported in DMs currently!\nNot a DM? Then an error has occurred :(', + content: 'This command is not supported in DMs currently!\nNot a DM? Then the bot failed to get the guild info', }); return; } - // First check the permissions of the user + // Check the permissions of the user if (!interaction.memberPermissions?.has(PermissionFlagsBits.ManageChannels)) { - await interaction.followUp({ + await interaction.reply({ ephemeral: true, content: 'You do not have the permission to manage channels!', }); @@ -205,63 +225,138 @@ const commands: Record = { return; } - // Check if the channel is valid - if (!await checkIfChannelIdIsValid(youtubeChannelId)) { - await interaction.reply({ - ephemeral: true, - content: 'That channel doesn\'t exist!', - }); - return; - } + switch (targetPlatform) { + case 'youtube': + // Check that the channel ID is in a valid format + if (platformUserId.length != 24 || !platformUserId.startsWith('UC')) { + await interaction.reply({ + ephemeral: true, + content: 'Invalid YouTube channel ID format! Each channel ID should be 24 characters long and start with "UC". Handles are currently not supported. Need to find the channel ID? We have a guide here: https://github.com/GalvinPython/feedr/wiki/Guide:-How-to-get-the-YouTube-Channel-ID', + }); + return; + } - // Check if the channel is already being tracked in the guild - if (await checkIfGuildIsTrackingChannelAlready(youtubeChannelId, guildId)) { - await interaction.reply({ - ephemeral: true, - content: 'This channel is already being tracked!', - }); - return; - } + // Check if the channel is valid + if (!await checkIfChannelIdIsValid(platformUserId)) { + await interaction.reply({ + ephemeral: true, + content: 'That channel doesn\'t exist!', + }); + return; + } - // Check if the channel is already being tracked globally - if (!await checkIfChannelIsAlreadyTracked(youtubeChannelId)) { - if (!await addNewChannelToTrack(youtubeChannelId)) { - await interaction.reply({ - ephemeral: true, - content: 'An error occurred while trying to add the channel to track! This is a new channel being tracked globally, please report this error!', - }); + // Check if the channel is already being tracked in the guild + if (await checkIfGuildIsTrackingChannelAlready(platformUserId, guildId)) { + await interaction.reply({ + ephemeral: true, + content: 'This channel is already being tracked!', + }); + return; + } + + // Check if the channel is already being tracked globally + if (!await checkIfChannelIsAlreadyTracked(platformUserId)) { + if (!await addNewChannelToTrack(platformUserId)) { + await interaction.reply({ + ephemeral: true, + content: 'An error occurred while trying to add the channel to track! This is a new channel being tracked globally, please report this error!', + }); + return; + } + } + + // Add the guild to the database + if (await addNewGuildToTrackChannel(guildId, platformUserId, discordChannelId, interaction.options.get('role')?.value as string ?? null)) { + const youtubeChannelInfo = await getChannelDetails(platformUserId) + await interaction.reply({ + ephemeral: true, + content: `Started tracking the channel ${youtubeChannelInfo?.channelName ?? platformUserId} in ${targetChannel.name}!`, + }); + } else { + await interaction.reply({ + ephemeral: true, + content: 'An error occurred while trying to add the guild to track the channel! Please report this error!', + }); + } return; - } - } + case 'twitch': + // Check if the streamer exists by getting the ID + const streamerId = await getStreamerId(platformUserId); - // Add the guild to the database - if (await addNewGuildToTrackChannel(guildId, youtubeChannelId, discordChannelId, interaction.options.get('role')?.value as string ?? null)) { - const channelIdInfo = await client.channels.fetch(discordChannelId); - if (channelIdInfo && (channelIdInfo.type === ChannelType.GuildText || channelIdInfo.type === ChannelType.GuildAnnouncement)) { - const youtubeChannelInfo = await getChannelDetails(youtubeChannelId) + if (!streamerId) { + await interaction.reply({ + ephemeral: true, + content: 'That streamer doesn\'t exist!', + }); + return; + } - await interaction.reply({ - ephemeral: true, - content: `Started tracking the channel ${youtubeChannelInfo?.channelName ?? youtubeChannelId} in ${channelIdInfo.name}!`, - }); - } else { - await interaction.reply({ - ephemeral: true, - content: 'The channel to send updates to is not a text channel! Please make sure to set a text channel!', - }); - } - return; + // Check if the channel is already being tracked in the guild + if (await twitchCheckIfGuildIsTrackingChannelAlready(streamerId, guildId)) { + await interaction.reply({ + ephemeral: true, + content: 'This streamer is already being tracked!', + }); + return; + } + + // Check if the channel is already being tracked globally + if (!await twitchCheckIfChannelIsAlreadyTracked(streamerId)) { + const isLive = await checkIfStreamerIsLive(streamerId); + if (!await twitchAddNewChannelToTrack(streamerId, isLive)) { + await interaction.reply({ + ephemeral: true, + content: 'An error occurred while trying to add the streamer to track! This is a new streamer being tracked globally, please report this error!', + }); + return; + } + } + + // Add the guild to the database + if (await twitchAddNewGuildToTrackChannel(guildId, streamerId, discordChannelId, interaction.options.get('role')?.value as string ?? null)) { + await interaction.reply({ + ephemeral: true, + content: `Started tracking the streamer ${platformUserId} (${streamerId}) in ${targetChannel.name}!`, + }); + } else { + await interaction.reply({ + ephemeral: true, + content: 'An error occurred while trying to add the guild to track the streamer! Please report this error!', + }); + } + return; + default: + console.error('This should never happen'); + break; } } }, untrack: { data: { - options: [{ - name: 'youtube_channel', - description: 'Enter the YouTube channel ID to stop tracking', - type: 3, - required: true, - }], + options: [ + { + name: 'platform', + description: 'Select a supported platform to track', + type: 3, + required: true, + choices: [ + { + name: 'Twitch', + value: 'twitch', + }, + { + name: 'YouTube', + value: 'youtube', + }, + ] + }, + { + name: 'user_id', + description: 'Enter the YouTube/Twitch channel ID to stop tracking', + type: 3, + required: true, + } + ], name: 'untrack', description: 'Stop a channel from being tracked in this guild!', integration_types: [0, 1], @@ -269,54 +364,87 @@ const commands: Record = { }, execute: async (interaction: CommandInteraction) => { // Get the YouTube Channel ID - const youtubeChannelId = interaction.options.get('youtube_channel')?.value as string; + const youtubeChannelId = interaction.options.get('user_id')?.value as string; + const platform = interaction.options.get('platform')?.value as string; const guildId = interaction.guildId; - // Deferring the reply is not the best practice, - // but in case the network/database is slow, it's better to defer the reply - // so we don't get a timeout error - await interaction.deferReply(); - // DMs are currently not supported, so throw back an error if (!guildId || interaction.channel?.isDMBased()) { - await interaction.followUp({ + await interaction.reply({ ephemeral: true, content: 'This command is not supported in DMs currently!\nNot a DM? Then an error has occurred :(', }); return; } - // First check the permissions of the user + // Check the permissions of the user if (!interaction.memberPermissions?.has(PermissionFlagsBits.ManageChannels)) { - await interaction.followUp({ + await interaction.reply({ ephemeral: true, content: 'You do not have the permission to manage channels!', }); return; } - // Check if the channel is already being tracked in the guild - if (!await checkIfGuildIsTrackingChannelAlready(youtubeChannelId, guildId)) { - await interaction.followUp({ + // Platform check (to shut up TS) + if (platform != 'youtube' && platform != 'twitch') { + await interaction.reply({ ephemeral: true, - content: 'This channel is not being tracked in this guild!', + content: 'Platform not supported! Please select a platform to track!', }); return; } - // Add the guild to the database - if (await stopGuildTrackingChannel(guildId, youtubeChannelId)) { - await interaction.followUp({ - ephemeral: true, - content: 'Successfully stopped tracking the channel!', - }); - } else { - await interaction.followUp({ + // Check if the channel is not being tracked in the guild + if (!await checkIfGuildIsTrackingChannelAlready(youtubeChannelId, guildId)) { + await interaction.reply({ ephemeral: true, - content: 'An error occurred while trying to stop tracking the channel! Please report this error!', + content: 'This channel is not being tracked in this guild!', }); + return; + } + + // Remove the guild from the database + switch (platform) { + case 'youtube': + if (await stopGuildTrackingChannel(guildId, youtubeChannelId)) { + await interaction.reply({ + ephemeral: true, + content: 'Successfully stopped tracking the channel!', + }); + } else { + await interaction.reply({ + ephemeral: true, + content: 'An error occurred while trying to stop tracking the channel! Please report this error!', + }); + } + return; + case 'twitch': + // get the twitch id for the streamer + const streamerId = await getStreamerId(youtubeChannelId); + if (!streamerId) { + await interaction.reply({ + ephemeral: true, + content: 'An error occurred while trying to get the streamer ID! Please report this error!', + }); + return; + } + + if (await twitchStopGuildTrackingChannel(guildId, youtubeChannelId)) { + await interaction.reply({ + ephemeral: true, + content: 'Successfully stopped tracking the streamer!', + }); + } else { + await interaction.reply({ + ephemeral: true, + content: 'An error occurred while trying to stop tracking the streamer! Please report this error!', + }); + } + return; + default: + return; } - return; } } }; @@ -331,4 +459,4 @@ for (const key in commands) { } } -export default commandsMap; \ No newline at end of file +export default commandsMap; diff --git a/src/config.ts b/src/config.ts index f793514..4b53993 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,6 +1,7 @@ // FILL IN THIS INFORMATION IN .ENV -export const config: { [key: string]: string | number | undefined } = { - updateInterval: process.env?.CONFIG_UPDATE_INTERVAL ? parseInt(process.env?.CONFIG_UPDATE_INTERVAL) * 1000 : undefined, +export const config: { [key: string]: string | number } = { + updateIntervalYouTube: process.env?.CONFIG_UPDATE_INTERVAL_YOUTUBE ? parseInt(process.env?.CONFIG_UPDATE_INTERVAL_YOUTUBE) * 1000 : 60_000, + updateIntervalTwitch: process.env?.CONFIG_UPDATE_INTERVAL_TWITCH ? parseInt(process.env?.CONFIG_UPDATE_INTERVAL_TWITCH) * 1000 : 60_000, } export const env: { [key: string]: string | undefined } = { @@ -11,4 +12,6 @@ export const env: { [key: string]: string | undefined } = { mysqlUser: process.env?.MYSQL_USER, mysqlPassword: process.env?.MYSQL_PASSWORD, mysqlDatabase: process.env?.MYSQL_DATABASE, + twitchClientId: process.env?.TWITCH_CLIENT_ID, + twitchClientSecret: process.env?.TWITCH_CLIENT_SECRET, }; \ No newline at end of file diff --git a/src/database.ts b/src/database.ts index d0203fa..d483fb9 100644 --- a/src/database.ts +++ b/src/database.ts @@ -17,14 +17,23 @@ export async function initTables(): Promise { latest_video_id VARCHAR(255) UNIQUE ); `; + const createDiscordTable = ` CREATE TABLE IF NOT EXISTS discord ( guild_id VARCHAR(255), guild_channel_id VARCHAR(255) NOT NULL, - discord_youtube_channel_id VARCHAR(255) NOT NULL, + guild_platform VARCHAR(255) NOT NULL, + platform_user_id VARCHAR(255) NOT NULL, guild_ping_role VARCHAR(255), INDEX (guild_id), - INDEX (discord_youtube_channel_id) + INDEX (platform_user_id) + ); + `; + + const createTwitchTable = ` + CREATE TABLE IF NOT EXISTS twitch ( + twitch_channel_id VARCHAR(255) NOT NULL PRIMARY KEY, + is_live BOOLEAN ); `; @@ -44,10 +53,20 @@ export async function initTables(): Promise { console.log("Discord table created"); } }); + pool.query(createTwitchTable, (err) => { + if (err) { + console.error("Error creating Twitch table:", err); + return false + } else { + console.log("Twitch table created"); + } + }); return true; } //#endregion + +//#region YouTube // These two functions are for checking/adding a new channel to the youtube table export async function checkIfChannelIsAlreadyTracked(channelId: string) { const query = `SELECT * FROM youtube WHERE youtube_channel_id = ?`; @@ -87,7 +106,7 @@ export async function addNewChannelToTrack(channelId: string) { } export async function checkIfGuildIsTrackingChannelAlready(channelId: string, guild_id: string) { - const query = `SELECT * FROM discord WHERE discord_youtube_channel_id = ? AND guild_id = ?`; + const query = `SELECT * FROM discord WHERE platform_user_id = ? AND guild_id = ?`; return new Promise((resolve, reject) => { pool.query(query, [channelId, guild_id], (err, results) => { if (err) { @@ -102,7 +121,7 @@ export async function checkIfGuildIsTrackingChannelAlready(channelId: string, gu export async function addNewGuildToTrackChannel(guild_id: string, channelId: string, guild_channel_id: string, guild_ping_role: string | null) { - const query = `INSERT INTO discord (guild_id, discord_youtube_channel_id, guild_channel_id, guild_ping_role) VALUES (?, ?, ?, ?)`; + const query = `INSERT INTO discord (guild_id, platform_user_id, guild_channel_id, guild_ping_role, guild_platform) VALUES (?, ?, ?, ?, 'youtube')`; return new Promise((resolve, reject) => pool.query(query, [guild_id, channelId, guild_channel_id, guild_ping_role], (err) => { if (err) { @@ -130,7 +149,7 @@ export async function getAllChannelsToTrack() { } export async function getGuildsTrackingChannel(channelId: string) { - const query = `SELECT * FROM discord WHERE discord_youtube_channel_id = ?`; + const query = `SELECT * FROM discord WHERE platform_user_id = ?`; return new Promise((resolve, reject) => pool.query(query, [channelId], (err, results: any) => { if (err) { @@ -170,3 +189,118 @@ export async function stopGuildTrackingChannel(guild_id: string, channelId: stri }) ); } + +//#endregion +//#region Twitch +export async function twitchGetAllChannelsToTrack() { + const query = `SELECT * FROM twitch`; + return new Promise((resolve, reject) => + pool.query(query, (err, results: any) => { + if (err) { + console.error("Error getting all Twitch channels to track:", err); + reject(err); + } else { + resolve(results); + } + }) + ); +} + +export async function twitchCheckIfChannelIsAlreadyTracked(channelId: string) { + const query = `SELECT * FROM twitch WHERE twitch_channel_id = ?`; + return new Promise((resolve, reject) => { + pool.query(query, [channelId], (err, results) => { + if (err) { + console.error("Error checking if Twitch channel is already tracked:", err); + reject(err); + } else { + resolve(results.length > 0); + } + }); + }); +} + +export async function twitchCheckIfGuildIsTrackingChannelAlready(channelId: string, guild_id: string) { + const query = `SELECT * FROM discord WHERE platform_user_id = ? AND guild_id = ?`; + return new Promise((resolve, reject) => { + pool.query(query, [channelId, guild_id], (err, results) => { + if (err) { + console.error("Error checking if guild is tracking Twitch channel already:", err); + reject(err); + } else { + resolve(results.length > 0); + } + }); + }); +} + +export async function twitchAddNewChannelToTrack(channelId: string, isLive: boolean) { + const query = `INSERT INTO twitch (twitch_channel_id, is_live) VALUES (?, ?)`; + return new Promise((resolve, reject) => + pool.query(query, [channelId, isLive], (err) => { + if (err) { + console.error("Error adding Twitch channel to track:", err); + reject(err); + } else { + resolve(true); + } + }) + ); +} + +export async function twitchAddNewGuildToTrackChannel(guild_id: string, channelId: string, guild_channel_id: string, guild_ping_role: string | null) { + const query = `INSERT INTO discord (guild_id, platform_user_id, guild_channel_id, guild_ping_role, guild_platform) VALUES (?, ?, ?, ?, 'twitch')`; + return new Promise((resolve, reject) => + pool.query(query, [guild_id, channelId, guild_channel_id, guild_ping_role], (err) => { + if (err) { + console.error("Error adding guild to track Twitch channel:", err); + reject(err); + } else { + resolve(true); + } + }) + ); +} + +export async function twitchGetGuildsTrackingChannel(channelId: string) { + const query = `SELECT * FROM discord WHERE platform_user_id = ?`; + return new Promise((resolve, reject) => + pool.query(query, [channelId], (err, results: any) => { + if (err) { + console.error("Error getting guilds tracking Twitch channel:", err); + reject(err); + } else { + resolve(results); + } + }) + ); +} + +export async function twitchUpdateIsLive(channelId: string, isLive: boolean) { + const query = `UPDATE twitch SET is_live = ? WHERE twitch_channel_id = ?`; + return new Promise((resolve, reject) => + pool.query(query, [isLive, channelId], (err) => { + if (err) { + console.error("Error updating is live:", err); + reject(err); + } else { + resolve(true); + } + }) + ); +} + +export async function twitchStopGuildTrackingChannel(guild_id: string, channelId: string) { + const query = `DELETE FROM discord WHERE guild_id = ? AND platform_user_id = ?`; + return new Promise((resolve, reject) => + pool.query(query, [guild_id, channelId], (err) => { + if (err) { + console.error("Error stopping guild tracking Twitch channel:", err); + reject(err); + } else { + resolve(true); + } + }) + ); +} +//#endregion \ No newline at end of file diff --git a/src/events/ready.ts b/src/events/ready.ts index 29f57a4..7531449 100644 --- a/src/events/ready.ts +++ b/src/events/ready.ts @@ -1,7 +1,8 @@ import { ActivityType, Events, PresenceUpdateStatus } from 'discord.js'; import client from '../index'; -import fetchLatestUploads from '../utils/fetchLatestUploads'; +import fetchLatestUploads from '../utils/youtube/fetchLatestUploads'; import { config } from '../config'; +import { checkIfStreamersAreLive } from '../utils/twitch/checkIfStreamerIsLive'; // update the bot's presence function updatePresence() { @@ -25,5 +26,7 @@ client.once(Events.ClientReady, async (bot) => { updatePresence(); fetchLatestUploads(); setInterval(updatePresence, 60000); - setInterval(fetchLatestUploads, config.updateInterval); + setInterval(fetchLatestUploads, config.updateIntervalYouTube as number); + checkIfStreamersAreLive(); + setInterval(checkIfStreamersAreLive, config.updateIntervalTwitch as number); }); diff --git a/src/index.ts b/src/index.ts index 0b480c2..fd4ef69 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,5 @@ // Check if all the required environment variables are set -import { config, env } from './config.ts'; - -if (!config.updateInterval) { - throw new Error('You MUST provide an update interval in .env!'); -} +import { env } from './config.ts'; if (!env.discordToken || env.discordToken === 'YOUR_DISCORD_TOKEN') { throw new Error('You MUST provide a discord token in .env!'); @@ -33,13 +29,21 @@ if (!env.mysqlDatabase || env.mysqlDatabase === 'YOUR_DATABASE_NAME') { throw new Error('You MUST provide a database name in .env!'); } +if (!env.twitchClientId || env.twitchClientId === 'YOUR_TWITCH_CLIENT_ID') { + throw new Error('You MUST provide a Twitch client ID in .env!'); +} + +if (!env.twitchClientSecret || env.twitchClientSecret === 'YOUR_TWITCH_CLIENT_SECRET') { + throw new Error('You MUST provide a Twitch client secret in .env!'); +} + // If everything is set up correctly, continue with the bot import { Client, GatewayIntentBits, REST, Routes, type APIApplicationCommand } from 'discord.js'; import commandsMap from './commands.ts'; import fs from 'fs/promises'; import path from 'path'; import { initTables } from './database.ts'; -import fetchLatestUploads from './utils/fetchLatestUploads.ts'; +import { getTwitchToken } from './utils/twitch/auth.ts'; const client = new Client({ intents: [ @@ -70,6 +74,12 @@ if (!await initTables()) { throw new Error('Error initializing tables'); } +// Get Twitch token +if (!await getTwitchToken()) { + throw new Error('Error getting Twitch token'); +} + +// Login to Discord client.login(env.discordToken); export default client diff --git a/src/types/database.d.ts b/src/types/database.d.ts index 5438608..fd98662 100644 --- a/src/types/database.d.ts +++ b/src/types/database.d.ts @@ -1,4 +1,9 @@ export interface dbYouTube { channelId: string; lastVideoId: string; +} + +export interface dbTwitch { + twitch_channel_id: string; + is_live: boolean; } \ No newline at end of file diff --git a/src/utils/twitch/auth.ts b/src/utils/twitch/auth.ts new file mode 100644 index 0000000..c3ae7aa --- /dev/null +++ b/src/utils/twitch/auth.ts @@ -0,0 +1,21 @@ +import { env } from "../../config"; + +export let twitchToken: string | undefined; + +export async function getTwitchToken(): Promise { + const res = await fetch(`https://id.twitch.tv/oauth2/token?client_id=${env.twitchClientId}&client_secret=${env.twitchClientSecret}&grant_type=client_credentials`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (!res.ok) { + console.error("Error fetching Twitch token:", res.statusText); + return false; + } + + const data = await res.json(); + twitchToken = data.access_token; + return true; +} \ No newline at end of file diff --git a/src/utils/twitch/checkIfStreamerIsLive.ts b/src/utils/twitch/checkIfStreamerIsLive.ts new file mode 100644 index 0000000..d9b6656 --- /dev/null +++ b/src/utils/twitch/checkIfStreamerIsLive.ts @@ -0,0 +1,95 @@ +import { twitchToken } from "./auth"; +import { env } from "../../config"; +import { twitchGetAllChannelsToTrack, twitchGetGuildsTrackingChannel, twitchUpdateIsLive } from "../../database"; +import type { dbTwitch } from "../../types/database"; +import client from "../.."; +import type { TextChannel } from "discord.js"; + +export async function checkIfStreamerIsLive(streamerId: string): Promise { + if (!twitchToken || !env.twitchClientId) { + console.error("Twitch token not found in checkIfStreamerIsLive"); + return false; + } + + const res = await fetch(`https://api.twitch.tv/helix/streams?user_id=${streamerId}`, { + headers: { + 'Client-ID': env.twitchClientId, + 'Authorization': `Bearer ${twitchToken}`, + }, + }); + + if (!res.ok) { + console.error("Error fetching stream data in checkIfStreamerIsLive:", res.statusText); + return false; + } + + const data = await res.json(); + console.log("Stream data:", data); + return data.data.length > 0; +} + +export async function checkIfStreamersAreLive(): Promise { + if (!twitchToken || !env.twitchClientId) { + console.error("Twitch token not found in checkIfStreamersAreLive"); + return; + } + + const allStreamerIds = await twitchGetAllChannelsToTrack(); + const chunkSize = 100; + const chunks = []; + + for (let i = 0; i < allStreamerIds.length; i += chunkSize) { + const chunk = allStreamerIds.slice(i, i + chunkSize); + chunks.push(chunk); + } + + for (const chunk of chunks) { + const urlQueries = chunk.map((streamerId: dbTwitch) => `user_id=${streamerId.twitch_channel_id}`).join("&"); + const res = await fetch(`https://api.twitch.tv/helix/streams?${urlQueries}`, { + headers: { + 'Client-ID': env.twitchClientId, + 'Authorization': `Bearer ${twitchToken}`, + }, + }); + + if (!res.ok) { + console.error("Error fetching stream data in checkIfStreamersAreLive:", res.statusText); + return; + } + + const data = await res.json(); + const allLiveStreamers = data.data.map((stream: any) => stream.user_id); + + for (const streamerId of chunk) { + const isLive = allLiveStreamers.includes(streamerId.twitch_channel_id); + const needsUpdate = isLive !== Boolean(streamerId.is_live); + let streamerName; + + if (isLive) { + streamerName = data.data.find((stream: any) => stream.user_id === streamerId.twitch_channel_id).user_name; + } + + console.log(`[Twitch] ${streamerId.twitch_channel_id} is live:`, isLive, '. Was live:', Boolean(streamerId.is_live), '. Needs update:', needsUpdate); + + if (needsUpdate) { + // Update the database + console.log(`Updating ${streamerId.twitch_channel_id} to be ${isLive ? "live" : "offline"}`); + await twitchUpdateIsLive(streamerId.twitch_channel_id, isLive); + + if (isLive) { + // Get all guilds that are tracking this streamer + const guildsTrackingStreamer = await twitchGetGuildsTrackingChannel(streamerId.twitch_channel_id) + for (const guild of guildsTrackingStreamer) { + // Send a message to the channel + const channel = await client.channels.fetch(guild.guild_channel_id); + await (channel as TextChannel).send( + guild.guild_ping_role ? `<@&${guild.guild_ping_role}> ${streamerName} is now live!` : `${streamerId.twitch_channel_id} is now live!` + ); + } + } else { + console.log(`[Twitch] ${streamerId.twitch_channel_id} is offline!`); + } + } + } + } +} \ No newline at end of file diff --git a/src/utils/twitch/getStreamerId.ts b/src/utils/twitch/getStreamerId.ts new file mode 100644 index 0000000..affe01f --- /dev/null +++ b/src/utils/twitch/getStreamerId.ts @@ -0,0 +1,29 @@ +import { twitchToken } from "./auth"; +import { env } from "../../config"; + +export async function getStreamerId(streamerId: string): Promise { + if (!twitchToken || !env.twitchClientId) { + console.error("Twitch token not found in getStreamerId"); + return null; + } + + const res = await fetch(`https://api.twitch.tv/helix/users?login=${streamerId.toLowerCase()}`, { + headers: { + 'Client-ID': env.twitchClientId, + 'Authorization': `Bearer ${twitchToken}`, + }, + }); + + if (!res.ok) { + console.error("Error fetching stream data in checkIfStreamerIsLive:", res.statusText); + return null + } + + const data = await res.json(); + console.log("Streamer data:", data); + if (data.data && data.data.length > 0) { + return data.data[0].id; + } else { + return null; + } +} \ No newline at end of file diff --git a/src/utils/checkIfChannelIdIsValid.ts b/src/utils/youtube/checkIfChannelIdIsValid.ts similarity index 89% rename from src/utils/checkIfChannelIdIsValid.ts rename to src/utils/youtube/checkIfChannelIdIsValid.ts index bf808a8..b77766a 100644 --- a/src/utils/checkIfChannelIdIsValid.ts +++ b/src/utils/youtube/checkIfChannelIdIsValid.ts @@ -1,4 +1,4 @@ -import { env } from "../config" +import { env } from "../../config" export default async function checkIfChannelIdIsValid(channelId: string) { const res = await fetch(`https://www.googleapis.com/youtube/v3/channels?part=snippet&id=${channelId}&key=${env.youtubeApiKey}`); diff --git a/src/utils/fetchLatestUploads.ts b/src/utils/youtube/fetchLatestUploads.ts similarity index 96% rename from src/utils/fetchLatestUploads.ts rename to src/utils/youtube/fetchLatestUploads.ts index a490a9e..4026817 100644 --- a/src/utils/fetchLatestUploads.ts +++ b/src/utils/youtube/fetchLatestUploads.ts @@ -1,7 +1,7 @@ -import { env } from "../config"; -import { getAllChannelsToTrack, getGuildsTrackingChannel, updateVideoId } from "../database"; +import { env } from "../../config"; +import { getAllChannelsToTrack, getGuildsTrackingChannel, updateVideoId } from "../../database"; import { ChannelType, TextChannel } from "discord.js"; -import client from ".."; +import client from "../.."; import getChannelDetails from "./getChannelDetails"; export default async function fetchLatestUploads() { diff --git a/src/utils/getChannelDetails.ts b/src/utils/youtube/getChannelDetails.ts similarity index 94% rename from src/utils/getChannelDetails.ts rename to src/utils/youtube/getChannelDetails.ts index e4dd969..6e5aedd 100644 --- a/src/utils/getChannelDetails.ts +++ b/src/utils/youtube/getChannelDetails.ts @@ -1,4 +1,4 @@ -import { env } from "../config"; +import { env } from "../../config"; interface channelDetails { channelName: string;