├── .env.example ├── .gitignore ├── README.md ├── extends └── js │ ├── announcer-bot.js │ ├── base-announcer.js │ ├── constants.js │ ├── errors.js │ ├── helpers.js │ └── platforms │ ├── _list.js │ ├── caffeine-announcer.js │ ├── goodgame-announcer.js │ ├── kick-announcer.js │ ├── nuum-announcer.js │ ├── openrec-announcer.js │ ├── trovo-announcer.js │ ├── twitch-announcer.js │ ├── vk-play-announcer.js │ └── youtube-announcer.js ├── index.js └── package.json /.env.example: -------------------------------------------------------------------------------- 1 | YOUTUBE_API_KEY= 2 | YOUTUBE_STREAMER_ID= 3 | 4 | TROVO_CHANNEL_NAME= 5 | 6 | TWITCH_CLIENT_ID= 7 | TWITCH_CLIENT_SECRET= 8 | TWITCH_CHANNEL_NAME= 9 | 10 | VKPLAY_CHANNEL_NAME= 11 | 12 | CAFFEINE_CHANNEL_NAME= 13 | 14 | GOODGAME_CHANNEL_NAME= 15 | 16 | KICK_CHANNEL_NAME= 17 | 18 | OPENREC_CHANNEL_NAME= 19 | 20 | NUUM_CHANNEL_NAME= 21 | 22 | DISCORD_API_KEY= 23 | DISCORD_CHANNEL_ID= -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Project exclude paths 2 | /node_modules/ 3 | /yarn.lock 4 | /.idea/ 5 | /.git/ 6 | /.env -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # discord-streams-announcer-bot 2 | 3 | [“”](https://www.youtube.com/) ⠀ [“”](https://trovo.live/) ⠀ [“”](https://www.twitch.tv/) ⠀ 4 |
5 | [“”](https://kick.com/) ⠀ [“”](https://live.vkplay.ru/) ⠀ [“”](https://www.caffeine.tv/) 6 |
7 | [“”](https://www.openrec.tv/) ⠀ [“”](https://goodgame.ru/) ⠀⠀[“”](https://nuum.ru/) ⠀⠀ 8 | 9 | ## [EN] 10 | 11 | Allows you to announce the start of your streams: Youtube, Trovo, Twitch, Kick, VkPlay, Caffeine, GoodGame, OpenRec and Nuum! 12 | 13 | You can safely use this project and/or parts of it in your own projects\ 14 | To get started you need to install [NodeJS](https://nodejs.org/en/) and [Yarn](https://classic.yarnpkg.com/lang/en/docs/install/) (optional), and then: 15 | 16 | 1. Clone the repository 17 | 2. `yarn install` (or `npm install`) 18 | 3. Clone `.env.example` into `.env`, fill in 19 | 4. Run `node -r dotenv/config index` 20 | 21 |
22 | How to fill .env 23 | 24 | ## 25 | 26 | It is obligatory to fill in the fields for Discord bot, the other items - as needed\ 27 | If you only need YouTube or WASD, you can leave Twitch or Trovo fields blank 28 | 29 | ### Discord 30 | 31 | `DISCORD_API_KEY` - access key in [discord.developer](https://discord.com/developers/applications)\ 32 | `DISCORD_CHANNEL_ID` - ID of the discord channel where the webhook announcement should be, obtained by copying and pasting 33 | 34 | ### YouTube (optional) 35 | 36 | `YOUTUBE_API_KEY` - access key in [youtube.developer](https://developers.google.com/youtube/v3), you can specify several keys separated by commas, the interval of requests will adjust to the number of transferred keys\ 37 | `YOUTUBE_STREAMER_ID` - the ID of youtube-user, for example link `https://www.youtube.com/channel/UCTt1aYtL8sFGViCUSH07CVw`, where `UCTt1aYtL8sFGViCUSH07CVw` is that ID. Note that a link of the form `https://www.youtube.com/c/СЕРЕГАПИРАТ` is a dick because of the old Google+, you need exactly the ID, not the username 38 | 39 | ### Trovo (optional) 40 | 41 | `TROVO_CHANNEL_NAME` - trovo channel nickname, e.g. link `https://trovo.live/s/serega_pirat`, where `serega_pirat` is the same nickname 42 | 43 | ### Twitch (optional) 44 | 45 | `TWITCH_CLIENT_ID` - client_id from twitch develop\ 46 | `TWITCH_CLIENT_SECRET` - client_secret from twitch develop\ 47 | `TWITCH_CHANNEL_NAME` - the nickname of the twitch channel, for example the link `https://twitch.tv/serega_pirat` where `serega_pirat` is the same nickname 48 | 49 | ### Kick (optional) 50 | 51 | `KICK_CHANNEL_NAME` - the nickname of the kick-channel, for example a link `https://kick.com/serega-pirat15`, where `serega-pirat15` - the same nickname\ 52 | If you get the error `For error "Missing libgbm.so.1"` - run the command `sudo apt-get install libgbm-dev` 53 | 54 | ### VK PLAY (optional) 55 | 56 | `VKPLAY_CHANNEL_NAME` - the nickname of the vklive-channel, for example a link `https://vkplay.live/serega_pirat`, where `serega_pirat` - the same nickname 57 | 58 | ### CAFFEINE.TV (optional) 59 | 60 | `CAFFEINE_CHANNEL_NAME` - the nickname of the caffeine-channel, for example a link `https://www.caffeine.tv/serega_pirat`, where `serega_pirat` - the same nickname 61 | 62 | ### GOODGAME.RU (optional) 63 | 64 | `GOODGAME_CHANNEL_NAME` - the nickname of the goodgame-channel, for example a link `https://goodgame.ru/serega_pirat`, where `serega_pirat` - the same nickname 65 | 66 | ### OPENREC (optional) 67 | 68 | `OPENREC_CHANNEL_NAME` - the nickname of the openRec-channel, for example a link `https://www.openrec.tv/user/serega_pirat`, where `serega_pirat` - the same nickname 69 | 70 | ### Nuum (optional) 71 | 72 | `NUUM_CHANNEL_NAME` - the nickname of the nuum-channel, for example a link `https://nuum.ru/channel/serega_pirat`, where `serega_pirat` - the same nickname 73 | 74 |
75 | 76 | ## [RU] 77 | 78 | Позволяет анонсировать начало ваших стримов: Youtube, WASD, Trovo, Twitch, VkPlay, Caffeine, GoodGame, OpenRec и Nuum! 79 | 80 | Вы можете спокойно использовать данный проект и/или его отдельные части в своих проектах\ 81 | Для начала работы необходимо установить [NodeJS](https://nodejs.org/en/) и [Yarn](https://classic.yarnpkg.com/lang/en/docs/install/) (не обязательно), и следом: 82 | 83 | 1. Клонировать репозиторий 84 | 2. `yarn install` (или `npm install`) 85 | 3. Клонировать `.env.example` в `.env`, заполнить 86 | 4. Запустить `node -r dotenv/config index` 87 | 88 |
89 | Как заполнить .env 90 | 91 | ## 92 | 93 | Обязательно необходимо заполнить поля для Discord бота, остальные пункты - по мере надобности\ 94 | Если вам нужен только YouTube или WASD, то поля Твич или Трово можно оставить пустыми 95 | 96 | ### Дискорд 97 | 98 | `DISCORD_API_KEY` - ключ доступа в [discord.developer](https://discord.com/developers/applications)\ 99 | `DISCORD_CHANNEL_ID` - ID дискорд канала, где должен быть webhook анонс, получается путём копирования и вставки 100 | 101 | ### YouTube (не обязательно) 102 | 103 | `YOUTUBE_API_KEY` - ключ доступа в [youtube.developer](https://developers.google.com/youtube/v3), можно указать несколько ключей через запятую, интервал запросов подстроится под количество переданных ключей\ 104 | `YOUTUBE_STREAMER_ID` - ID youtube-пользователя, например ссылка `https://www.youtube.com/channel/UCTt1aYtL8sFGViCUSH07CVw`, где `UCTt1aYtL8sFGViCUSH07CVw` - тот самый ID. Обращаю внимание, что ссылка вида `https://www.youtube.com/c/СЕРЕГАПИРАТ` - залупа из-за старых гугл+, нужен именно ID, а не имя пользователя 105 | 106 | ### Трово (не обязательно) 107 | 108 | `TROVO_CHANNEL_NAME` - никнейм трово-канала, например ссылка `https://trovo.live/s/serega_pirat`, где `serega_pirat` - тот самый никнейм 109 | 110 | ### Твич (не обязательно) 111 | 112 | `TWITCH_CLIENT_ID` - client_id из twitch develop\ 113 | `TWITCH_CLIENT_SECRET` - client_secret из twitch develop\ 114 | `TWITCH_CHANNEL_NAME` - никнейм twitch-канала, например ссылка `https://twitch.tv/serega_pirat`, где `serega_pirat` - тот самый никнейм 115 | 116 | ### Кик (не обязательно) 117 | 118 | `KICK_CHANNEL_NAME` - никнейм кик-канала, например ссылка `https://kick.com/serega-pirat15`, где `serega-pirat15` - тот самый никнейм\ 119 | Если выдаёт ошибку `For error "Missing libgbm.so.1"` - выполнить команду `sudo apt-get install libgbm-dev` 120 | 121 | ### ВК ПЛЕЙ (не обязательно) 122 | 123 | `VKPLAY_CHANNEL_NAME` - никнейм вкплей-канала, например ссылка `https://vkplay.live/serega_pirat`, где `serega_pirat` - тот самый никнейм 124 | 125 | ### CAFFEINE.TV (не обязательно) 126 | 127 | `CAFFEINE_CHANNEL_NAME` - никнейм caffeine-канала, например ссылка `https://www.caffeine.tv/serega_pirat`, где `serega_pirat` - тот самый никнейм 128 | 129 | ### GOODGAME.RU (не обязательно) 130 | 131 | `GOODGAME_CHANNEL_NAME` - никнейм goodgame-канала, например ссылка `https://goodgame.ru/serega_pirat`, где `serega_pirat` - тот самый никнейм 132 | 133 | ### OPENREC (не обязательно) 134 | 135 | `OPENREC_CHANNEL_NAME` - никнейм openRec-канала, например ссылка `https://www.openrec.tv/user/serega_pirat`, где `serega_pirat` - тот самый никнейм 136 | 137 | ### Nuum (не обязательно) 138 | 139 | `NUUM_CHANNEL_NAME` - никнейм nuum-канала, например ссылка `https://nuum.ru/channel/serega_pirat`, где `serega_pirat` - тот самый никнейм 140 | 141 |
142 | -------------------------------------------------------------------------------- /extends/js/announcer-bot.js: -------------------------------------------------------------------------------- 1 | import list from './platforms/_list.js' 2 | 3 | import {Client} from "discord.js" 4 | import {discord_intents as intents} from "./constants.js" 5 | 6 | export default class AnnouncerBot { 7 | constructor() { 8 | this.platforms = list 9 | this.main = this.main.bind(this) 10 | } 11 | 12 | async main() { 13 | console.log('Initialization') 14 | for (let platform of this.platforms) { 15 | platform.runQueue(this.client) 16 | } 17 | } 18 | 19 | init() { 20 | const apiKey = process.env.DISCORD_API_KEY 21 | if (!apiKey) { 22 | console.error('api key not specified') 23 | } 24 | 25 | this.client = new Client({intents, disableEveryone: false}) 26 | this.client.login(apiKey) 27 | .then(this.main) 28 | .catch(this.error) 29 | } 30 | 31 | error(...args) { 32 | console.log(...args) 33 | } 34 | } -------------------------------------------------------------------------------- /extends/js/base-announcer.js: -------------------------------------------------------------------------------- 1 | import {default_headers as headers, webhook_user} from "./constants.js" 2 | import {EmbedBuilder} from "discord.js" 3 | import fetch from "node-fetch" 4 | 5 | const discordChannelId = process.env.DISCORD_CHANNEL_ID 6 | 7 | export default class BaseAnnouncer { 8 | platformName = null 9 | platformLogo = null 10 | platformLink = null 11 | platformColor = 0x000000 12 | channelName = null 13 | channelNameFormat = null 14 | interval = 3e4 15 | token = null 16 | 17 | constructor() { 18 | this.queue = [] 19 | 20 | this.checkStream = this.checkStream.bind(this) 21 | this.sendMessage = this.sendMessage.bind(this) 22 | this.log = this.log.bind(this) 23 | } 24 | 25 | async checkStream() { 26 | this.error('This method is not implemented') 27 | } 28 | 29 | async sendMessage(data, stream_id) { 30 | this.log(`Video ${stream_id} found, trying to send a message to the channel ${discordChannelId}`) 31 | 32 | const discordChannel = this.client.channels.cache.get(discordChannelId) 33 | if (discordChannel) { 34 | const {title, preview, videoId = null} = data 35 | const {name, avatar} = webhook_user 36 | const link = this.platformLink + (videoId ?? this.channelName) 37 | const embed = new EmbedBuilder() 38 | .setTitle(title) 39 | .setAuthor({ 40 | name: `${this.channelNameFormat ?? this.channelName}`, 41 | url: link 42 | }) 43 | .setColor(this.platformColor) 44 | .setURL(link) 45 | .setImage(preview) 46 | .setFooter({ 47 | iconURL: this.platformLogo, 48 | text: `Стрим на ${this.platformName}` 49 | }) 50 | 51 | const context = await discordChannel.createWebhook({name, avatar}) 52 | this.log(`Sending a message`) 53 | 54 | await context.send({ 55 | content: '@here', 56 | embeds: [embed] 57 | }) 58 | await context.delete() 59 | } 60 | } 61 | 62 | async getData(link) { 63 | this.log(`Get a stream info ${this.channelNameFormat ?? this.channelName}`) 64 | 65 | const response = await fetch(link, { 66 | headers, 67 | cache: 'no-store' 68 | }) 69 | 70 | const textRaw = await response.text() 71 | return JSON.parse(textRaw) 72 | } 73 | 74 | async runQueue(_client) { 75 | this.client = _client 76 | if (!this.channelName) { 77 | return this.error(`You didn\'t fill in the channel name`) 78 | } 79 | 80 | await this.checkStream() 81 | setInterval(this.checkStream, this.interval) 82 | } 83 | 84 | log(text) { 85 | console.log(`[${this.platformName}] ${text}`) 86 | } 87 | 88 | error(text) { 89 | console.error(`[${this.platformName}] ${text}`) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /extends/js/constants.js: -------------------------------------------------------------------------------- 1 | import {GatewayIntentBits} from 'discord.js' 2 | 3 | export const youtube_timeout_limit = 9e5 // 15 минут 4 | export const kick_timeout_limit = 6e5 // 10 минут 5 | 6 | export const discord_intents = [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages] 7 | export const default_headers = {Accept: 'application/json'} 8 | export const webhook_user = { 9 | name: 'Оповещатель', 10 | avatar: 'https://i.imgur.com/DIxR2g7.png', 11 | } 12 | 13 | export const NOTAUTHORIZED = "Not authorized" 14 | export const UNEXPECTED = "Unexpected" 15 | export const SESSION_RESTORED = "Session restored" -------------------------------------------------------------------------------- /extends/js/errors.js: -------------------------------------------------------------------------------- 1 | export const STREAMER_OFFLINE = 'Streamer offline' 2 | export const ALERT_ALREADY_CREATED = 'An alert has already been created about this stream, skip' 3 | export const INFO_NOT_FOUND = 'Streaming information not found' 4 | export const INVALID_SESSION_RESTORED = 'Invalid restored' -------------------------------------------------------------------------------- /extends/js/helpers.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Конвертирует API ключи вида AAA,BBB,CCC (из .env) в массив вида ['AAA', 'BBB', 'CCC'] 3 | * @param string 4 | */ 5 | export function convertApiKeys(string = '') { 6 | if (typeof string === 'string' && string.length > 0) { 7 | return string.split(',') 8 | } 9 | return [] 10 | } 11 | 12 | /** 13 | * Получаем количество минут из миллисекунд 14 | * @param milliseconds 15 | * @returns {number} 16 | */ 17 | export function getMinutesFromMilliseconds(milliseconds) { 18 | return milliseconds / 60 / 1000 19 | } -------------------------------------------------------------------------------- /extends/js/platforms/_list.js: -------------------------------------------------------------------------------- 1 | import YoutubeAnnouncer from "./youtube-announcer.js" 2 | import TrovoAnnouncer from "./trovo-announcer.js" 3 | import TwitchAnnouncer from "./twitch-announcer.js" 4 | import VkPlayAnnouncer from "./vk-play-announcer.js" 5 | import CaffeineAnnouncer from "./caffeine-announcer.js" 6 | import GoodGameAnnouncer from "./goodgame-announcer.js" 7 | import KickAnnouncer from "./kick-announcer.js" 8 | import OpenRecAnnouncer from "./openrec-announcer.js" 9 | import NuumAnnouncer from "./nuum-announcer.js" 10 | 11 | export default [ 12 | new YoutubeAnnouncer(), 13 | new TrovoAnnouncer(), 14 | new TwitchAnnouncer(), 15 | new VkPlayAnnouncer(), 16 | new CaffeineAnnouncer(), 17 | new GoodGameAnnouncer(), 18 | new KickAnnouncer(), 19 | new OpenRecAnnouncer(), 20 | new NuumAnnouncer(), 21 | ] -------------------------------------------------------------------------------- /extends/js/platforms/caffeine-announcer.js: -------------------------------------------------------------------------------- 1 | import BaseAnnouncer from "../base-announcer.js" 2 | import {ALERT_ALREADY_CREATED, INFO_NOT_FOUND, STREAMER_OFFLINE} from "../errors.js" 3 | 4 | const caffeineChannelName = process.env.CAFFEINE_CHANNEL_NAME 5 | 6 | export default class CaffeineAnnouncer extends BaseAnnouncer { 7 | platformName = 'Caffeine' 8 | platformLogo = 'https://www.caffeine.tv/favicon-v3-32x32.png' 9 | platformLink = 'https://www.caffeine.tv/' 10 | platformColor = 0x0000ff 11 | channelName = caffeineChannelName 12 | 13 | async checkStream() { 14 | const link = `https://api.caffeine.tv/social/public/${caffeineChannelName}/featured` 15 | 16 | try { 17 | const data = await this.getData(link) 18 | const {broadcast_info: info = {}, is_live = false} = data 19 | const {broadcast_id: stream_id = null} = info 20 | 21 | if (is_live !== true || stream_id === null) { 22 | return this.log(STREAMER_OFFLINE) 23 | } 24 | if (this.queue.includes(stream_id)) { 25 | return this.log(ALERT_ALREADY_CREATED) 26 | } 27 | 28 | const {broadcast_title: title, preview_image_path: previewChunk} = info 29 | const preview = 'https://api-sam.caffeine.tv/thumb' + previewChunk 30 | 31 | this.queue.push(stream_id) 32 | this.sendMessage({ 33 | title, 34 | preview 35 | }, stream_id) 36 | } catch (error) { 37 | return this.log(INFO_NOT_FOUND) 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /extends/js/platforms/goodgame-announcer.js: -------------------------------------------------------------------------------- 1 | import BaseAnnouncer from "../base-announcer.js" 2 | import {ALERT_ALREADY_CREATED, INFO_NOT_FOUND, STREAMER_OFFLINE} from "../errors.js" 3 | 4 | const goodGameChannelName = process.env.GOODGAME_CHANNEL_NAME 5 | const GOODGAME_STATUS_LIVE = 'Live' 6 | 7 | export default class GoodGameAnnouncer extends BaseAnnouncer { 8 | platformName = 'GOODGAME' 9 | platformLogo = 'https://static.goodgame.ru/images/favicon/apple-icon-76x76.png' 10 | platformLink = 'https://goodgame.ru/channel/' 11 | platformColor = 0x233056 12 | channelName = goodGameChannelName 13 | 14 | async checkStream() { 15 | const link = `https://goodgame.ru/api/getchannelstatus?id=${this.channelName}&fmt=json` 16 | 17 | try { 18 | const data = await this.getData(link) 19 | if (data instanceof Array) { 20 | return this.log(INFO_NOT_FOUND) 21 | } 22 | 23 | const [result = {}] = Object.values(data) 24 | const {stream_id = 0, status = false, title = '', thumb: preview = ''} = result 25 | 26 | if (status !== GOODGAME_STATUS_LIVE) { 27 | this.queue = [] 28 | return this.log(STREAMER_OFFLINE) 29 | } 30 | if (this.queue.includes(stream_id)) { 31 | return this.log(ALERT_ALREADY_CREATED) 32 | } 33 | 34 | this.queue.push(stream_id) 35 | this.sendMessage({ 36 | title, 37 | preview: `https:${preview}` 38 | }, stream_id) 39 | } catch (error) { 40 | console.error(error) 41 | } 42 | } 43 | } -------------------------------------------------------------------------------- /extends/js/platforms/kick-announcer.js: -------------------------------------------------------------------------------- 1 | import BaseAnnouncer from "../base-announcer.js" 2 | import {ALERT_ALREADY_CREATED, STREAMER_OFFLINE} from "../errors.js" 3 | import {KickApiWrapper} from "kick.com-api" 4 | import {kick_timeout_limit} from "../constants.js" 5 | 6 | const kickChannelName = process.env.KICK_CHANNEL_NAME 7 | 8 | export default class KickAnnouncer extends BaseAnnouncer { 9 | platformName = 'Kick' 10 | platformLogo = 'https://i.imgur.com/rpTKwrq.png' 11 | platformLink = 'https://kick.com/' 12 | platformColor = 0x53fc18 13 | channelName = kickChannelName 14 | interval = kick_timeout_limit 15 | 16 | async checkStream() { 17 | try { 18 | const {livestream} = await this.getData() 19 | if (!livestream) { 20 | return this.log(STREAMER_OFFLINE) 21 | } 22 | 23 | const {id: stream_id = 0, session_title: title = '', thumbnail: {url: preview = ''} = {}} = livestream 24 | if (this.queue.includes(stream_id)) { 25 | return this.log(ALERT_ALREADY_CREATED) 26 | } 27 | 28 | this.queue.push(stream_id) 29 | this.sendMessage({title, preview}, stream_id) 30 | } catch (error) { 31 | return this.log(error) 32 | } 33 | } 34 | 35 | async getData() { 36 | this.log(`Get a stream info ${this.channelNameFormat ?? this.channelName}`) 37 | 38 | const kickApi = new KickApiWrapper({ 39 | puppeteer: { 40 | args: ['--no-sandbox'] 41 | } 42 | }) 43 | 44 | return await kickApi.fetchChannelData(this.channelName) 45 | } 46 | } -------------------------------------------------------------------------------- /extends/js/platforms/nuum-announcer.js: -------------------------------------------------------------------------------- 1 | import BaseAnnouncer from "../base-announcer.js" 2 | import {ALERT_ALREADY_CREATED, STREAMER_OFFLINE} from "../errors.js" 3 | 4 | const nuumChannelName = process.env.NUUM_CHANNEL_NAME 5 | 6 | export default class NuumAnnouncer extends BaseAnnouncer { 7 | platformName = 'Nuum' 8 | platformLogo = 'https://nuum.ru/favicon.ico' 9 | platformLink = 'https://nuum.ru/channel/' 10 | platformColor = 0x633BF5 11 | channelName = nuumChannelName 12 | 13 | async checkStream() { 14 | const link = `https://nuum.ru/api/v2/broadcasts/public?channel_name=${this.channelName}` 15 | 16 | try { 17 | const {result: {channel = {}, media_container = {}}} = await this.getData(link) 18 | const {channel_is_live: is_live = false} = channel 19 | if (!is_live) { 20 | return this.log(STREAMER_OFFLINE) 21 | } 22 | 23 | const {media_container_streams: streams = [], media_container_name: title = ''} = media_container 24 | const [stream] = streams 25 | const {stream_id = 0, stream_media = {}} = stream 26 | if (this.queue.includes(stream_id)) { 27 | return this.log(ALERT_ALREADY_CREATED) 28 | } 29 | 30 | const [media] = stream_media 31 | const {media_meta: {media_preview_url: preview = ''}} = media 32 | 33 | this.queue.push(stream_id) 34 | this.sendMessage({ 35 | title, 36 | preview 37 | }, stream_id) 38 | } catch (error) { 39 | console.error(error) 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /extends/js/platforms/openrec-announcer.js: -------------------------------------------------------------------------------- 1 | import BaseAnnouncer from "../base-announcer.js" 2 | import {ALERT_ALREADY_CREATED, INFO_NOT_FOUND, STREAMER_OFFLINE} from "../errors.js" 3 | 4 | const openRecChannelName = process.env.OPENREC_CHANNEL_NAME 5 | 6 | export default class OpenRecAnnouncer extends BaseAnnouncer { 7 | platformName = 'OPENREC' 8 | platformLogo = 'https://i.imgur.com/pH9lRvn.png' 9 | platformLink = 'https://www.openrec.tv/user/' 10 | platformColor = 0xE7E5F0 11 | channelName = openRecChannelName 12 | 13 | async checkStream() { 14 | const link = `https://public.openrec.tv/external/api/v5/channels/${this.channelName}` 15 | 16 | try { 17 | const data = await this.getData(link) 18 | const {onair_broadcast_movies: movies = [], is_live = false, l_cover_image_url: preview = ''} = data 19 | const [movie = {}] = movies 20 | const {id: stream_id = 0, title = ''} = movie 21 | 22 | if (is_live !== true || stream_id === null) { 23 | return this.log(STREAMER_OFFLINE) 24 | } 25 | if (this.queue.includes(stream_id)) { 26 | return this.log(ALERT_ALREADY_CREATED) 27 | } 28 | 29 | this.queue.push(stream_id) 30 | this.sendMessage({ 31 | title, 32 | preview 33 | }, stream_id) 34 | } catch (error) { 35 | return this.log(INFO_NOT_FOUND) 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /extends/js/platforms/trovo-announcer.js: -------------------------------------------------------------------------------- 1 | import fetch from "node-fetch" 2 | import BaseAnnouncer from "../base-announcer.js" 3 | import {ALERT_ALREADY_CREATED, INFO_NOT_FOUND, STREAMER_OFFLINE} from "../errors.js" 4 | 5 | const trovoChannelName = process.env.TROVO_CHANNEL_NAME 6 | 7 | export default class TrovoAnnouncer extends BaseAnnouncer { 8 | platformName = 'Trovo' 9 | platformLogo = 'https://i.imgur.com/17ZhzQa.png' 10 | platformLink = 'https://trovo.live/s/' 11 | platformColor = 0x1cbc73 12 | channelName = trovoChannelName 13 | 14 | async checkStream() { 15 | const link = `https://api-web.trovo.live/graphql?qid=0` 16 | 17 | try { 18 | /** @param {result: {data: {live_LiveReaderService_GetLiveInfo: Object}}} result */ 19 | const [result] = await this.getData(link) 20 | if (!result.data?.live_LiveReaderService_GetLiveInfo) { 21 | return this.log(INFO_NOT_FOUND) 22 | } 23 | 24 | const {data: {live_LiveReaderService_GetLiveInfo: info = {}} = {}} = result 25 | const {isLive = false, programInfo = {}} = info 26 | const {id: stream_id = 0, title = '', coverUrl: preview = ''} = programInfo 27 | 28 | if (!isLive) { 29 | return this.log(STREAMER_OFFLINE) 30 | } 31 | if (this.queue.includes(stream_id)) { 32 | return this.log(ALERT_ALREADY_CREATED) 33 | } 34 | 35 | this.queue.push(stream_id) 36 | this.sendMessage({title, preview}, stream_id) 37 | } catch (error) { 38 | console.error(error) 39 | } 40 | } 41 | 42 | async getData(link) { 43 | this.log(`Get a stream info ${this.channelNameFormat ?? this.channelName}`) 44 | 45 | const body = JSON.stringify([{ 46 | operationName: "live_LiveReaderService_GetLiveInfo", 47 | variables: { 48 | params: { 49 | userName: this.channelName, 50 | requireDecorations: true 51 | } 52 | } 53 | }]) 54 | 55 | const response = await fetch(link, { 56 | method: 'POST', 57 | mode: 'no-cors', 58 | cache: 'no-store', 59 | body 60 | }) 61 | 62 | const textRaw = await response.text() 63 | return JSON.parse(textRaw) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /extends/js/platforms/twitch-announcer.js: -------------------------------------------------------------------------------- 1 | import BaseAnnouncer from "../base-announcer.js" 2 | import {NOTAUTHORIZED, SESSION_RESTORED, UNEXPECTED} from "../constants.js" 3 | import fetch from "node-fetch" 4 | import {ALERT_ALREADY_CREATED, INFO_NOT_FOUND, INVALID_SESSION_RESTORED, STREAMER_OFFLINE} from "../errors.js" 5 | 6 | const twitchChannelName = process.env.TWITCH_CHANNEL_NAME 7 | 8 | export default class TwitchAnnouncer extends BaseAnnouncer { 9 | platformName = 'Twitch' 10 | platformLogo = 'https://i.imgur.com/SJah69y.png' 11 | platformLink = 'https://www.twitch.tv/' 12 | platformColor = 0xa970ff 13 | channelName = twitchChannelName 14 | 15 | async checkStream() { 16 | this.log(`Get a stream info ${this.channelNameFormat ?? this.channelName}`) 17 | 18 | try { 19 | const userData = await this.getUser(twitchChannelName) 20 | if (userData.length === 0) { 21 | return this.log(INFO_NOT_FOUND) 22 | } 23 | 24 | const [user] = userData 25 | const {id: userId} = user 26 | const streamData = await this.getStreamInfo(userId) 27 | if (streamData.length === 0) { 28 | return this.log(STREAMER_OFFLINE) 29 | } 30 | 31 | const [stream] = streamData 32 | const {id: stream_id} = stream 33 | if (this.queue.includes(stream_id)) { 34 | return this.log(ALERT_ALREADY_CREATED) 35 | } 36 | 37 | const {title, thumbnail_url} = stream 38 | const preview = thumbnail_url 39 | .replace('{width}', 1600) 40 | .replace('{height}', 900) 41 | 42 | this.queue.push(stream_id) 43 | this.sendMessage({ 44 | title, 45 | preview: preview + `?v=${Math.round(Math.random() * 1e10)}` 46 | }, stream_id) 47 | } catch (e) { 48 | switch (e.message) { 49 | case NOTAUTHORIZED: 50 | this.token = null 51 | const result = await this.authorize() 52 | if (result) { 53 | await this.checkStream() 54 | } 55 | break 56 | case UNEXPECTED: 57 | default: 58 | console.error(e) 59 | } 60 | } 61 | } 62 | 63 | get init () { 64 | return { 65 | headers: { 66 | Authorization: `Bearer ${this.token}`, 67 | 'Client-ID': process.env.TWITCH_CLIENT_ID 68 | } 69 | } 70 | } 71 | 72 | async get(url) { 73 | const userResponse = await fetch(url, this.init) 74 | const {status = 200, data = []} = await userResponse.json() 75 | 76 | if (status === 401) { 77 | throw new Error(NOTAUTHORIZED) 78 | } 79 | 80 | switch (status) { 81 | case 200: 82 | return data 83 | case 401: 84 | throw new Error(NOTAUTHORIZED) 85 | default: 86 | throw new Error(UNEXPECTED) 87 | } 88 | } 89 | 90 | async getUser(name) { 91 | return await this.get('https://api.twitch.tv/helix/users?login=' + name) 92 | } 93 | 94 | async getStreamInfo(userId) { 95 | return await this.get('https://api.twitch.tv/helix/streams?user_id=' + userId) 96 | } 97 | 98 | async authorize() { 99 | const clientId = `client_id=${process.env.TWITCH_CLIENT_ID}` 100 | const clientSecret = `client_secret=${process.env.TWITCH_CLIENT_SECRET}` 101 | 102 | const response = await fetch(`https://id.twitch.tv/oauth2/token?${clientId}&${clientSecret}&grant_type=client_credentials`, { 103 | method: 'post' 104 | }) 105 | const result = await response.json() 106 | const {access_token = null} = result 107 | if (access_token) { 108 | this.log(SESSION_RESTORED) 109 | this.token = access_token 110 | return true 111 | } 112 | 113 | this.log(INVALID_SESSION_RESTORED) 114 | return false 115 | } 116 | } -------------------------------------------------------------------------------- /extends/js/platforms/vk-play-announcer.js: -------------------------------------------------------------------------------- 1 | import BaseAnnouncer from "../base-announcer.js" 2 | import {ALERT_ALREADY_CREATED, STREAMER_OFFLINE} from "../errors.js" 3 | 4 | const vkPlayChannelName = process.env.VKPLAY_CHANNEL_NAME 5 | 6 | export default class VkPlayAnnouncer extends BaseAnnouncer { 7 | platformName = 'VK PLAY' 8 | platformLogo = 'https://static.vkplay.live/static/favicon.png' 9 | platformLink = 'https://vkplay.live/' 10 | platformColor = 0x8e92de 11 | channelName = vkPlayChannelName 12 | 13 | async checkStream() { 14 | const link = `https://api.vkplay.live/v1/blog/${vkPlayChannelName}/public_video_stream` 15 | 16 | try { 17 | const {id: stream_id, isOnline: is_live = false, title, previewUrl: preview} = await this.getData(link) 18 | if (is_live !== true) { 19 | return this.log(STREAMER_OFFLINE) 20 | } 21 | if (this.queue.includes(stream_id)) { 22 | return this.log(ALERT_ALREADY_CREATED) 23 | } 24 | 25 | this.queue.push(stream_id) 26 | this.sendMessage({ 27 | title, 28 | preview 29 | }, stream_id) 30 | } catch (error) { 31 | console.error(error) 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /extends/js/platforms/youtube-announcer.js: -------------------------------------------------------------------------------- 1 | import {youtube_timeout_limit} from "../constants.js" 2 | import {convertApiKeys} from "../helpers.js" 3 | import BaseAnnouncer from "../base-announcer.js" 4 | import {ALERT_ALREADY_CREATED, INFO_NOT_FOUND, STREAMER_OFFLINE} from "../errors.js"; 5 | 6 | const youtubeChannelID = process.env.YOUTUBE_STREAMER_ID 7 | const youtubeApiKeys = convertApiKeys(process.env.YOUTUBE_API_KEY) 8 | 9 | export default class YoutubeAnnouncer extends BaseAnnouncer { 10 | platformName = 'Youtube' 11 | platformLogo = 'https://i.imgur.com/S4DsV5h.png' 12 | platformLink = 'https://youtu.be/' 13 | platformColor = 0xff0000 14 | channelName = youtubeChannelID 15 | interval = youtube_timeout_limit 16 | 17 | async checkStream() { 18 | const currentTime = new Date() 19 | const interval = this.interval / youtubeApiKeys.length 20 | const currentSecondsInThisHour = (currentTime.getMinutes() * 60 + currentTime.getSeconds()) 21 | const currentYoutubeApiKeyIndex = (currentSecondsInThisHour / (interval / 1000) ^ 0) % youtubeApiKeys.length 22 | const currentYoutubeApiKey = youtubeApiKeys[currentYoutubeApiKeyIndex] 23 | 24 | const link = `https://www.googleapis.com/youtube/v3/search?part=snippet&channelId=${this.channelName}&eventType=live&type=video&key=${currentYoutubeApiKey}` 25 | 26 | try { 27 | const data = await this.getData(link) 28 | if (!data || !data.items || data.items.length === 0) { 29 | console.log(JSON.stringify(data)) 30 | return this.log(INFO_NOT_FOUND) 31 | } 32 | 33 | const [item] = data.items 34 | if (!item.id || !item.id.videoId) { 35 | return this.log(STREAMER_OFFLINE) 36 | } 37 | 38 | if (this.queue.includes(item.id.videoId)) { 39 | return this.log(ALERT_ALREADY_CREATED) 40 | } 41 | 42 | const {snippet, id: {videoId}} = item 43 | const {channelTitle, title} = snippet 44 | const preview = snippet.thumbnails.high.url 45 | const stream_id = item.id.videoId 46 | 47 | this.channelNameFormat = channelTitle 48 | this.queue.push(stream_id) 49 | this.sendMessage({ 50 | title, 51 | preview, 52 | videoId 53 | }, stream_id) 54 | } catch (error) { 55 | console.error(error) 56 | } 57 | } 58 | 59 | async runQueue(_client) { 60 | this.client = _client 61 | if (!this.channelName) { 62 | return this.error('You didn\'t fill in the channel name') 63 | } 64 | if (!youtubeApiKeys || youtubeApiKeys.length === 0) { 65 | return this.error('You didn\'t fill in the api keys') 66 | } 67 | 68 | const interval = this.interval / youtubeApiKeys.length 69 | await this.checkStream() 70 | setInterval(this.checkStream, interval) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import AnnouncerBot from './extends/js/announcer-bot.js' 2 | 3 | const bot = new AnnouncerBot(process.env.DISCORD_API_KEY) 4 | bot.init() -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "dependencies": { 7 | "discord.js": "^14.14.1", 8 | "dotenv": "^16.0.0", 9 | "kick.com-api": "^0.1.65", 10 | "node-fetch": "^3.2.4" 11 | }, 12 | "type": "module" 13 | } 14 | --------------------------------------------------------------------------------