├── .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 |
--------------------------------------------------------------------------------