Compare commits

..

10 Commits

Author SHA1 Message Date
DeSqBlocki 3d75257c19 Merge branch 'master' of https://github.com/DeSqBlocki/NotifyMe 2025-01-01 17:11:24 +01:00
DeSqBlocki 585d2e51a2 defers replies now 2025-01-01 17:11:22 +01:00
DeSqBlocki 6a578dbd7e Update README.md 2024-12-30 17:09:08 +01:00
DeSqBlocki 6ec2e1e169 Updated Interval to 60 Seconds for Rate Limit Adherance 2024-12-27 17:52:31 +01:00
DeSqBlocki aae2ec1bbb Added logic to remove no-longer-valid channels from cache and handle those TypeErrors. But does not remove it from database 2024-12-22 17:18:48 +01:00
DeSqBlocki d50364275d renamed channel.channel to channel.name for better readability 2024-12-21 19:27:58 +01:00
DeSqBlocki 1040a08712 Added Functionality for Notification and Live Detection 2024-12-21 19:27:30 +01:00
DeSqBlocki 641788dd6e Added Sanitization to InfoChannel 2024-12-21 17:59:56 +01:00
DeSqBlocki aa31e8f5a0 Made Clients available globally via exports and sanitized channel names 2024-12-21 17:32:07 +01:00
DeSqBlocki f9db8b8fbe updated gitignore 2024-12-21 17:31:16 +01:00
5 changed files with 218 additions and 175 deletions
-1
View File
@@ -1,3 +1,2 @@
node_modules node_modules
.env .env
channels.json
+9 -3
View File
@@ -5,6 +5,12 @@ Notifies you, when a subscribed channel goes live on Twitch.tv
(Only works when joined on my private server for fetching reason) (Only works when joined on my private server for fetching reason)
Usage: Usage:
- !add <channel> # Shows current stream info, if live
- !remove <channel> - /channel info <twitch_channel>
- !list
# Un/Subscribes to a channel
- /channel subscribe <twitch_channel>
- /channel unsubscribe <twitch_channel>
# Lists your subscribed channels
- /channel list
+62 -47
View File
@@ -1,23 +1,20 @@
const { configDotenv } = require('dotenv'); const { configDotenv } = require('dotenv');
const { MongoClient } = require('mongodb');
const TwitchApi = require("node-twitch").default
const tClient = new TwitchApi({
client_id: process.env.TTV_ID,
client_secret: process.env.TTV_SECRET
});
configDotenv configDotenv
const mClient = new MongoClient(process.env.MONGO_URL);
const { SlashCommandBuilder, EmbedBuilder } = require('discord.js'); const { SlashCommandBuilder, EmbedBuilder } = require('discord.js');
const { tClient, mClient } = require('../../index');
function escapeMarkdown(input){
return input.replace(/([*_#\[!\]()`>+\-.|])/g, '\\$1');
}
async function SubscribeChannel(interaction) { async function SubscribeChannel(interaction) {
const channelName = encodeURI(await interaction.options.getString('channel')).toLowerCase() const channelName = encodeURI(await interaction.options.getString('channel')).toLowerCase()
const userID = interaction.user.id const userID = interaction.user.id
try { try {
let streamData = await tClient.getStreams({ channel: channelName }) let streamData = await tClient.getStreams({ name: channelName })
//console.log(await tClient.searchChannels({query: `${channelName}`})) // returns too many results and not exact match first //console.log(await tClient.searchChannels({query: `${channelName}`})) // returns too many results and not exact match first
} catch (error) { } catch (error) {
return interaction.reply({ return interaction.editReply({
content: `**[ERROR]:** Could not validate channel name for *${channelName}*!\r\n${error}`, content: `**[ERROR]:** Could not validate channel name for *${escapeMarkdown(channelName)}*!\r\n${error}`,
ephemeral: true ephemeral: true
}).then(setTimeout(() => { }).then(setTimeout(() => {
interaction.deleteReply() interaction.deleteReply()
@@ -27,12 +24,12 @@ async function SubscribeChannel(interaction) {
const db = mClient.db('notifyme') const db = mClient.db('notifyme')
const channelsColl = db.collection('channels') const channelsColl = db.collection('channels')
const find = await channelsColl.find({ channel: channelName }).toArray() const find = await channelsColl.find({ name: channelName }).toArray()
if (find[0]) { if (find[0]) {
if (find[0].users.includes(userID)) { if (find[0]?.subscribers?.includes(userID)) {
return interaction.reply({ return interaction.editReply({
content: `**[WARNING]:** You are already subscribed to *${channelName}*!`, content: `**[WARNING]:** You are already subscribed to *${escapeMarkdown(channelName)}*!`,
ephemeral: true ephemeral: true
}).then(setTimeout(() => { }).then(setTimeout(() => {
interaction.deleteReply() interaction.deleteReply()
@@ -42,20 +39,20 @@ async function SubscribeChannel(interaction) {
try { try {
const res = await channelsColl.findOneAndUpdate({ const res = await channelsColl.findOneAndUpdate({
channel: channelName name: channelName
}, { }, {
$set: { $set: {
channel: channelName, name: channelName,
}, },
$push: { $push: {
users: userID subscribers: userID
} }
}, { }, {
upsert: true, upsert: true,
returnDocument: 'after' returnDocument: 'after'
}) })
} catch (error) { } catch (error) {
return interaction.reply({ return interaction.editReply({
content: `**[ERROR]:** There was an issue updating the database!\r\n${error}`, content: `**[ERROR]:** There was an issue updating the database!\r\n${error}`,
ephemeral: true ephemeral: true
}).then(setTimeout(() => { }).then(setTimeout(() => {
@@ -63,8 +60,8 @@ async function SubscribeChannel(interaction) {
}, 5000)) }, 5000))
} }
//console.log(res) // debug //console.log(res) // debug
return interaction.reply({ return interaction.editReply({
content: `**[SUCCESS]:** You are now subscribed to *${channelName}*!`, content: `**[SUCCESS]:** You are now subscribed to *${escapeMarkdown(channelName)}*!`,
ephemeral: true ephemeral: true
}).then(setTimeout(() => { }).then(setTimeout(() => {
interaction.deleteReply() interaction.deleteReply()
@@ -78,12 +75,12 @@ async function UnsubscribeChannel(interaction) {
const db = mClient.db('notifyme') const db = mClient.db('notifyme')
const channelsColl = db.collection('channels') const channelsColl = db.collection('channels')
const find = await channelsColl.find({ channel: channelName }).toArray() const find = await channelsColl.find({ name: channelName }).toArray()
if (find[0]) { if (find[0]) {
if (!find[0].users.includes(userID)) { if (!find[0]?.subscribers?.includes(userID)) {
return interaction.reply({ return interaction.editReply({
content: `**[WARNING]:** You were not subscribed to *${channelName}*!`, content: `**[WARNING]:** You were not subscribed to *${escapeMarkdown(channelName)}*!`,
ephemeral: true ephemeral: true
}).then(setTimeout(() => { }).then(setTimeout(() => {
interaction.deleteReply() interaction.deleteReply()
@@ -93,29 +90,36 @@ async function UnsubscribeChannel(interaction) {
try { try {
const res = await channelsColl.findOneAndUpdate({ const res = await channelsColl.findOneAndUpdate({
channel: channelName name: channelName
}, { }, {
$set: { $set: {
channel: channelName, name: channelName,
}, },
$pull: { $pull: {
users: userID subscribers: userID
} }
}, { }, {
upsert: true, upsert: true,
returnDocument: 'after' returnDocument: 'after'
}) })
if(!res.subscribers[0]){
const newRes = await channelsColl.findOneAndDelete({
name: channelName
})
}
} catch (error) { } catch (error) {
return interaction.reply({ return interaction.editReply({
content: `**[ERROR]:** There was an issue with the database!\r\n${error}`, content: `**[ERROR]:** There was an issue with the database!\r\n${error}`,
ephemeral: true ephemeral: true
}).then(setTimeout(() => { }).then(setTimeout(() => {
interaction.deleteReply() interaction.deleteReply()
}, 5000)) }, 5000))
} }
//console.log(res) // debug //console.log(res) // debug
return interaction.reply({ return interaction.editReply({
content: `**[SUCCESS]:** You are no longer subscribed to *${channelName}*!`, content: `**[SUCCESS]:** You are no longer subscribed to *${escapeMarkdown(channelName)}*!`,
ephemeral: true ephemeral: true
}).then(setTimeout(() => { }).then(setTimeout(() => {
interaction.deleteReply() interaction.deleteReply()
@@ -128,19 +132,29 @@ async function ListChannel(interaction) {
const db = mClient.db('notifyme') const db = mClient.db('notifyme')
const channelsColl = db.collection('channels') const channelsColl = db.collection('channels')
const find = await channelsColl.find({ users: userID }).toArray() const find = await channelsColl.find({ subscribers: userID }).toArray()
if (!find[0]) {
return interaction.editReply({
content: `You are not subscribed to any channels!`,
ephemeral: true
}).then(setTimeout(() => {
interaction.deleteReply()
}, 5000))
}
var channelList = [] var channelList = []
find.forEach((document) => { find.forEach((document) => {
channelList.push(document.channel) channelList.push(escapeMarkdown(document.name))
}) })
const embed = new EmbedBuilder() const embed = new EmbedBuilder()
.setTitle('Lists of Subscribed Channels:') .setTitle('List of Subscribed Channels:')
.setDescription(channelList.join("\n")) .setDescription(channelList.join("\n"))
return interaction.reply({ return interaction.followUp({
embeds: [embed], embeds: [embed],
ephemeral: true ephemeral: true
}) }).then(setTimeout(() => {
interaction.deleteReply()
}, 60000))
} }
async function InfoChannel(interaction) { async function InfoChannel(interaction) {
@@ -156,7 +170,7 @@ async function InfoChannel(interaction) {
tags: streamData.data[0].tags tags: streamData.data[0].tags
} }
const embed = new EmbedBuilder() const embed = new EmbedBuilder()
.setTitle(`${liveData.streamer} is live!`) .setTitle(`${escapeMarkdown(liveData.streamer)} is live!`)
.setDescription(`They're playing ${liveData.game}`) .setDescription(`They're playing ${liveData.game}`)
.addFields( .addFields(
{ name: '\u200B', value: `${liveData.title}` }) { name: '\u200B', value: `${liveData.title}` })
@@ -164,32 +178,32 @@ async function InfoChannel(interaction) {
.setURL(`https://twitch.tv/${channelName}`) .setURL(`https://twitch.tv/${channelName}`)
let footer = "" let footer = ""
liveData.tags.forEach((tag) => { liveData.tags.forEach((tag) => {
footer += `${footer?',':''} #${tag}` footer += `#${tag} `
})
embed.setFooter({
text: footer
}) })
embed.setFooter({
text: footer
})
return interaction.reply({ return interaction.editReply({
embeds: [embed], embeds: [embed],
ephemeral: true ephemeral: true
}) }).then(setTimeout(() => {
interaction.deleteReply()
}, 60000))
} else { } else {
const embed = new EmbedBuilder() const embed = new EmbedBuilder()
.setTitle(`${channelName} is currently offline!`) .setTitle(`${channelName} is currently offline!`)
.setImage('https://media.discordapp.net/attachments/1061304429724319794/1319800268770250883/no-signal-tv-descendant-network-rainbow-bars-abstract-background-vector.jpg?ex=67674748&is=6765f5c8&hm=d539944a67fccec461b0183a9f0300e19ac485deda7b23090165088567f2c84f&=&format=webp') .setImage('https://media.discordapp.net/attachments/1061304429724319794/1319800268770250883/no-signal-tv-descendant-network-rainbow-bars-abstract-background-vector.jpg?ex=67674748&is=6765f5c8&hm=d539944a67fccec461b0183a9f0300e19ac485deda7b23090165088567f2c84f&=&format=webp')
.setURL(`https://twitch.tv/${channelName}`) .setURL(`https://twitch.tv/${channelName}`)
return interaction.reply({ return interaction.editReply({
embeds: [embed], embeds: [embed],
ephemeral: true ephemeral: true
}) })
} }
//streamData = streamData.
//console.log(await tClient.searchChannels({query: `${channelName}`})) // returns too many results and not exact match first
} catch (error) { } catch (error) {
console.error(error) console.error(error)
return interaction.reply({ return interaction.editReply({
content: `**[ERROR]:** Could not validate channel name for *${channelName}*!\r\n${error}`, content: `**[ERROR]:** Could not validate channel name for *${escapeMarkdown(channelName)}*!\r\n${error}`,
ephemeral: true ephemeral: true
}).then(setTimeout(() => { }).then(setTimeout(() => {
interaction.deleteReply() interaction.deleteReply()
@@ -240,6 +254,7 @@ module.exports = {
) )
), ),
async execute(interaction) { async execute(interaction) {
await interaction.deferReply()
switch (interaction.options._subcommand) { switch (interaction.options._subcommand) {
case 'subscribe': case 'subscribe':
SubscribeChannel(interaction) SubscribeChannel(interaction)
+133 -6
View File
@@ -1,17 +1,144 @@
const { Events, ActivityType } = require('discord.js'); const { Events, ActivityType, EmbedBuilder } = require('discord.js');
const { mClient, tClient, dClient } = require('../index');
// Global Maps and Sets
const channelList = new Map(); // Stores channel names and their subscribers
const isLive = new Set(); // Stores channels that are live
const isOffline = new Set(); // Stores channels that are offline
module.exports = { module.exports = {
name: Events.ClientReady, name: Events.ClientReady,
once: true, once: true,
async execute(client) { async execute(client) {
console.log(`Ready! Logged in as ${client.user.tag}`) console.log(`Ready! Logged in as ${client.user.tag}`);
// Set bot presence
client.user.setPresence({ client.user.setPresence({
activities: [{ activities: [{
name: 'Notyfing', name: 'Notifications for you',
type: ActivityType.Streaming, type: ActivityType.Streaming,
url: 'https://twitch.tv/desq_blocki' url: 'https://twitch.tv/desq_blocki',
}], }],
status: 'online' status: 'online',
}) });
// Main interval loop
setInterval(async () => {
await updateCache();
await checkLive();
}, 60000); // Every 60 seconds
},
};
// Update the cache from the database and clean up stale data
async function updateCache() {
const db = mClient.db('notifyme');
const channelsColl = db.collection('channels');
try {
const channels = await channelsColl.find({}).toArray();
const dbChannelNames = new Set(channels.map(channel => channel.name));
// Update channelList
channelList.clear();
channels.forEach(channel => channelList.set(channel.name, channel.subscribers));
// Remove stale entries from isLive and isOffline
cleanUpSet(isLive, dbChannelNames);
cleanUpSet(isOffline, dbChannelNames);
// Debug
// console.log({
// isLive: Array.from(isLive),
// isOffline: Array.from(isOffline),
// channels: Array.from(channelList.entries()),
// });
} catch (error) {
console.error('Error updating cache:', error);
} }
} }
// Notify subscribers about live status changes
async function checkLive() {
for (const [channelName, subscribers] of channelList) {
try {
// Attempt to fetch stream data
const streamData = await tClient.getStreams({ channel: channelName });
if (streamData?.data && streamData.data.length > 0) {
// Stream is live
if (!isLive.has(channelName)) {
await notifyLive(channelName, subscribers, streamData.data[0]);
}
} else {
// Stream is offline, do not remove from channelList
if (isLive.has(channelName)) {
console.log(`${channelName} went offline!`);
isLive.delete(channelName);
}
isOffline.add(channelName); // Mark as offline
}
} catch (error) {
// Specific handling for the TypeError
if (error instanceof TypeError && error.message.includes("Cannot read properties of undefined")) {
console.warn(`Channel "${channelName}" returned an invalid response. Removing from cache.`);
channelList.delete(channelName);
isLive.delete(channelName);
isOffline.delete(channelName);
} else {
// Log unexpected errors
console.error(`Error notifying for ${channelName}:`, error);
}
}
}
}
// Notify subscribers that a channel has gone live
async function notifyLive(channelName, subscribers, streamData) {
const liveData = {
streamer: streamData.user_name,
game: streamData.game_name,
title: streamData.title,
thumbnail: streamData.thumbnail_url.replace('-{width}x{height}', ''),
tags: streamData.tags,
};
const embed = new EmbedBuilder()
.setTitle(`${escapeMarkdown(liveData.streamer)} is live!`)
.setDescription(`They're playing ${liveData.game}`)
.addFields({ name: '\u200B', value: `${liveData.title}` })
.setImage(liveData.thumbnail)
.setURL(`https://twitch.tv/${channelName}`)
.setFooter({ text: liveData.tags.map(tag => `#${tag}`).join(' ') });
for (const subscriber of subscribers) {
try {
const user = await dClient.users.fetch(subscriber);
await user.send({ embeds: [embed] });
console.log(`${user.username} notified that ${channelName} went live!`);
} catch (error) {
console.error(`Error notifying ${subscriber}:`, error);
}
}
isLive.add(channelName);
isOffline.delete(channelName);
}
// Helper function to clean up stale data in a set
function cleanUpSet(set, validEntries) {
for (const entry of set) {
if (!validEntries.has(entry)) {
set.delete(entry);
}
}
}
// Escape special markdown characters
function escapeMarkdown(input) {
return input.replace(/([*_#\[!\]()`>+\-.|])/g, '\\$1');
}
+11 -115
View File
@@ -14,126 +14,22 @@ const dClient = new Client({
Partials.Channel Partials.Channel
] ]
}); });
dClient.commands = new Collection() // slash commands collection dClient.commands = new Collection() // slash commands collection
exports.dClient = dClient exports.dClient = dClient
const TwitchApi = require("node-twitch").default
const tClient = new TwitchApi({
client_id: process.env.TTV_ID,
client_secret: process.env.TTV_SECRET
});
exports.tClient = tClient
const { MongoClient } = require('mongodb');
const mClient = new MongoClient(process.env.MONGO_URL);
exports.mClient = mClient
fs.readdirSync('./handlers').forEach((handler) => { fs.readdirSync('./handlers').forEach((handler) => {
require(`./handlers/${handler}`) require(`./handlers/${handler}`)
}); });
dClient.login(process.env.DISCORD_Token) dClient.login(process.env.DISCORD_Token)
// async function getStreamdata(stream) {
// var streamData
// await TwitchClient.getStreams({ channel: stream }).then(async data => {
// const r = data.data[0]
// if (r) {
// //stream online
// if (!isLive.has(stream)) {
// //not yet in memory
// streamData = {
// "game": r.game_name,
// "title": r.title,
// }
// isLive.add(stream)
// } else {
// //already in memory
// }
// } else {
// //stream offline
// if (isLive.has(stream)) {
// //was in memory
// console.log(`${stream} went offline :c`)
// isLive.delete(stream)
// } else {
// //was not in memory
// }
// }
// })
// return streamData
// }
// async function doInterval() {
// const guild = client.guilds.fetch('848610258306072576')
// for (const [key, value] of Object.entries(channels)) {
// //key: channel, value: [ users ]
// let streamdata = await getStreamdata(key)
// if(streamdata){
// value.forEach(async userID =>{
// (await guild).members.fetch(userID)
// .then((u => {
// u.send(`https://twitch.tv/${key} is live!`)
// console.log(`${key} is live!`)
// }))
// })
// }
// }
// }
// client.once(Events.ClientReady, async client => {
// console.log(`Ready! Logged in as ${client.user.tag}`)
// doInterval()
// setInterval(async () => {
// doInterval()
// }, 2000)
// });
// client.on(Events.MessageCreate, async (msg) => {
// if (msg.author.bot) { return }
// console.log(msg.content)
// if (!msg.content.startsWith('!')) { return }
// const args = msg.content.substring(1).split(' ')
// switch (args[0]) {
// case "add":
// addChannel(args[1], msg)
// break;
// case "remove":
// delChannel(args[1], msg)
// break;
// case "list":
// listChannels(msg)
// default:
// break;
// }
// })
// function addChannel(channel, msg) {
// if (!channels[channel]) {
// channels[channel] = [msg.author.id]
// } else {
// channels[channel].push(msg.author.id)
// }
// fs.writeFileSync('./channels.json', JSON.stringify(channels), 'utf-8')
// msg.reply({
// content: `Subscribed to [${channel}] Notifications!`
// })
// }
// function delChannel(channel, msg) {
// let index = channels[channel].indexOf(msg.author.id)
// channels[channel].splice(index)
// if (channels[channel].length === 0) {
// delete channels[channel]
// }
// fs.writeFileSync('./channels.json', JSON.stringify(channels), 'utf-8')
// msg.reply({
// content: `Unsubscribed from [${channel}] Notifications!`
// })
// }
// function listChannels(msg){
// let subscribed = []
// let embed = new EmbedBuilder()
// .setTitle('Lists of Subscribed Channels:')
// for (const [key, value] of Object.entries(channels)) {
// if(value.includes(msg.author.id)){
// subscribed.push(key)
// }
// }
// embed.setDescription(subscribed.join("\n"))
// msg.reply({
// embeds: [embed]
// })
// }
// client.login(process.env.DISCORD_Token);