diff --git a/package-lock.json b/package-lock.json index 4f4d50365..4d94597af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,25 +20,24 @@ "dev": true }, "@babel/core": { - "version": "7.11.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.11.0.tgz", - "integrity": "sha512-mkLq8nwaXmDtFmRkQ8ED/eA2CnVw4zr7dCztKalZXBvdK5EeNUAesrrwUqjQEzFgomJssayzB0aqlOsP1vGLqg==", - "dev": true, - "requires": { - "@babel/code-frame": "^7.10.4", - "@babel/generator": "^7.11.0", - "@babel/helper-module-transforms": "^7.11.0", - "@babel/helpers": "^7.10.4", - "@babel/parser": "^7.11.0", - "@babel/template": "^7.10.4", - "@babel/traverse": "^7.11.0", - "@babel/types": "^7.11.0", + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.12.13.tgz", + "integrity": "sha512-BQKE9kXkPlXHPeqissfxo0lySWJcYdEP0hdtJOH/iJfDdhOCcgtNCjftCJg3qqauB4h+lz2N6ixM++b9DN1Tcw==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.12.13", + "@babel/generator": "^7.12.13", + "@babel/helper-module-transforms": "^7.12.13", + "@babel/helpers": "^7.12.13", + "@babel/parser": "^7.12.13", + "@babel/template": "^7.12.13", + "@babel/traverse": "^7.12.13", + "@babel/types": "^7.12.13", "convert-source-map": "^1.7.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.1", "json5": "^2.1.2", "lodash": "^4.17.19", - "resolve": "^1.3.2", "semver": "^5.4.1", "source-map": "^0.5.0" }, @@ -51,12 +50,6 @@ "requires": { "minimist": "^1.2.5" } - }, - "semver": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", - "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", - "dev": true } } }, @@ -71,20 +64,10 @@ "semver": "^6.3.0" }, "dependencies": { - "eslint-scope": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.0.tgz", - "integrity": "sha512-iiGRvtxWqgtx5m8EyQUJihBloE4EnYeGE/bz1wSPwJE6tZuJUtHlhqDM4Xj2ukE8Dyy1+HCZ4hE0fzIVMzb58w==", - "dev": true, - "requires": { - "esrecurse": "^4.1.0", - "estraverse": "^4.1.1" - } - }, - "eslint-visitor-keys": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz", - "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==", + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", "dev": true } } @@ -129,14 +112,6 @@ "@babel/helper-validator-option": "^7.12.11", "browserslist": "^4.14.5", "semver": "^5.5.0" - }, - "dependencies": { - "semver": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", - "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", - "dev": true - } } }, "@babel/helper-create-class-features-plugin": { @@ -977,14 +952,6 @@ "@babel/types": "^7.12.13", "core-js-compat": "^3.8.0", "semver": "^5.5.0" - }, - "dependencies": { - "semver": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", - "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", - "dev": true - } } }, "@babel/preset-modules": { @@ -2064,7 +2031,7 @@ } }, "erlpack": { - "version": "github:discordapp/erlpack#c514d36ec81a7a61ef90b75df261025ab046574d", + "version": "github:discordapp/erlpack#e27db8f82892bdb9b28a0547cc394d68b5d2242d", "from": "github:discordapp/erlpack", "requires": { "bindings": "^1.5.0", @@ -2226,6 +2193,22 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, + "eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "requires": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + } + }, + "eslint-visitor-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.0.0.tgz", + "integrity": "sha512-QudtT6av5WXels9WjIM7qz1XD1cWGvX4gGXvp/zBn9nXG02D0utdU3Em2m/QjTnrsk6bBjmCygl3rmj118msQQ==", + "dev": true + }, "globals": { "version": "12.4.0", "resolved": "https://registry.npmjs.org/globals/-/globals-12.4.0.tgz", @@ -2387,32 +2370,13 @@ } }, "eslint-scope": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", - "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.0.tgz", + "integrity": "sha512-iiGRvtxWqgtx5m8EyQUJihBloE4EnYeGE/bz1wSPwJE6tZuJUtHlhqDM4Xj2ukE8Dyy1+HCZ4hE0fzIVMzb58w==", "dev": true, "requires": { - "esrecurse": "^4.3.0", + "esrecurse": "^4.1.0", "estraverse": "^4.1.1" - }, - "dependencies": { - "esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "requires": { - "estraverse": "^5.2.0" - }, - "dependencies": { - "estraverse": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.2.0.tgz", - "integrity": "sha512-BxbNGGNm0RyRYvUdHpIwv9IWzeM9XClbOxwoATuFdOE7ZE6wHL+HQ5T8hoPM+zHvmKzzsEqhgy0GrQ5X13afiQ==", - "dev": true - } - } - } } }, "eslint-utils": { @@ -2422,20 +2386,12 @@ "dev": true, "requires": { "eslint-visitor-keys": "^1.1.0" - }, - "dependencies": { - "eslint-visitor-keys": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz", - "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==", - "dev": true - } } }, "eslint-visitor-keys": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.0.0.tgz", - "integrity": "sha512-QudtT6av5WXels9WjIM7qz1XD1cWGvX4gGXvp/zBn9nXG02D0utdU3Em2m/QjTnrsk6bBjmCygl3rmj118msQQ==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz", + "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==", "dev": true }, "espree": { @@ -2447,14 +2403,6 @@ "acorn": "^7.4.0", "acorn-jsx": "^5.3.1", "eslint-visitor-keys": "^1.3.0" - }, - "dependencies": { - "eslint-visitor-keys": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz", - "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==", - "dev": true - } } }, "esprima": { @@ -2481,12 +2429,20 @@ } }, "esrecurse": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.2.1.tgz", - "integrity": "sha512-64RBB++fIOAXPw3P9cy89qfMlvZEXZkqqJkjqqXIvzP5ezRZjW+lPWjw35UX/3EhUPFYbg5ER4JYgDw4007/DQ==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, "requires": { - "estraverse": "^4.1.0" + "estraverse": "^5.2.0" + }, + "dependencies": { + "estraverse": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.2.0.tgz", + "integrity": "sha512-BxbNGGNm0RyRYvUdHpIwv9IWzeM9XClbOxwoATuFdOE7ZE6wHL+HQ5T8hoPM+zHvmKzzsEqhgy0GrQ5X13afiQ==", + "dev": true + } } }, "estraverse": { @@ -3808,9 +3764,9 @@ "integrity": "sha1-VjsZx8HeiS4Jv8Ty/DDjwn8JUrk=" }, "semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", "dev": true }, "seq-queue": { diff --git a/package.json b/package.json index b8886d540..5fb610c27 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,7 @@ "zlib-sync": "^0.1.7" }, "devDependencies": { - "@babel/core": "^7.11.0", + "@babel/core": "^7.12.13", "@babel/eslint-parser": "^7.12.13", "@babel/plugin-proposal-class-properties": "^7.12.13", "@babel/plugin-proposal-private-methods": "^7.12.13", diff --git a/src/CommonFunctions.js b/src/CommonFunctions.js index bf1430d27..3213b0168 100644 --- a/src/CommonFunctions.js +++ b/src/CommonFunctions.js @@ -15,7 +15,7 @@ const welcomes = require('./resources/welcomes.json'); const { eventTypes, rewardTypes, opts, fissures, syndicates, twitter, conclave, deals, clantech, - resources, nightwave, twitch, + resources, nightwave, twitch } = require('./resources/trackables.json'); const rssFeeds = require('./resources/rssFeeds'); @@ -379,7 +379,7 @@ const chunkify = ({ if (checkTitle) { // strip the last title if it starts with a title if (string.endsWith('**')) { - const endTitle = string.matches(/\*\*(.*)\*\*\s*$/g)[1]; + const endTitle = (string.match(/\*\*(.*)\*\*\s*$/g)[1] || ''); string = string.replace(/\*\*(.*)\*\*\s*$/g, ''); // eslint-disable-line no-param-reassign breakIndex -= endTitle.length; } @@ -411,6 +411,7 @@ const markdinate = htmlString => htmlString .replace(/<\/li>\s*
  • /gm, '
  • \n
  • ') // clean up breaks between list items .replace(/\n/gm, '- ') // strip list items to bullets, replace later with emoji .replace(/ipsnoembed="false" /gm, '') // manually replace ipsnoembed, it causes issues given location + .replace(/ipsnoembed="true" /gm, '') // manually replace ipsnoembed, it causes issues given location .replace(/(.*)<\/a>/gm, '[$2]($1)') .replace(/&/gm, '&') // replace ampersand entity... it looks weird with some titles .replace(/<\/li>/gm, '') // strip li end tags diff --git a/src/Logger.js b/src/Logger.js index 75c9dc099..a9d06083c 100644 --- a/src/Logger.js +++ b/src/Logger.js @@ -26,7 +26,7 @@ const levels = { SILLY: 'grey', DEBUG: 'brightYellow', INFO: 'blue', - WARN: 'orange', + WARN: 'brightRed', ERROR: 'red', FATAL: 'magenta', }; @@ -40,6 +40,8 @@ const contexts = { TWITCH: 'magenta', WS: 'cyan', DB: 'blue', + TwitchApi: 'magenta', + TM: 'yellow', }; /** @@ -79,12 +81,13 @@ Object.keys(levels).forEach((level) => { if (Sentry) { Sentry.captureException(message); } - if (errorHook) { + if (errorHook && !l.logLevel === 'DEBUG') { // filter out api errors, they're largely unhelpful and unrecoverable if (message.stack && message.stack.startsWith('DiscordAPIError')) return; errorHook.send(new ErrorEmbed(message)); } else { console.error(simple); + console.error(message); } } }; diff --git a/src/bot.js b/src/bot.js index 04c3ac6e5..a4c5a9ce8 100644 --- a/src/bot.js +++ b/src/bot.js @@ -2,6 +2,8 @@ const { Client, WebhookClient } = require('discord.js'); +// const Feeder = require('rss-feed-emitter'); + const WorldStateClient = require('./resources/WorldStateClient'); const CommandManager = require('./CommandManager'); const EventHandler = require('./EventHandler'); @@ -11,6 +13,8 @@ const MessageManager = require('./settings/MessageManager'); const Database = require('./settings/Database'); const logger = require('./Logger'); +// const feeds = require('./resources/rssFeeds'); + const unlog = ['WS_CONNECTION_TIMEOUT']; /** @@ -150,6 +154,8 @@ class Genesis { this.clusterId = process.env.CLUSTER_ID || 0; // this.tracker = new Tracker(this); + // this.feeder; // for debugging + if (process.env.CONTROL_WH_ID) { this.controlHook = new WebhookClient(process.env.CONTROL_WH_ID, process.env.CONTROL_WH_TOKEN); } @@ -203,6 +209,16 @@ class Genesis { try { await this.client.login(this.token); this.logger.debug('Logged in with token.'); + + /* For Debugging: + this.feeder = new Feeder({ + userAgent: `RSS Feed Emitter | ${this.client.user.username}`, + skipFirstLoad: true, + }); + feeds.forEach((feed) => { + this.feeder.add({ url: feed.url, refresh: 900000 }); + }); + */ } catch (err) { const type = ((err && err.toString()) || '').replace(/Error \[(.*)\]: .*/ig, '$1'); if (!unlog.includes(type)) { diff --git a/src/commands/Ondemand/GetRSSUpdates.js b/src/commands/Ondemand/GetRSSUpdates.js index 8777175d9..feb7042d0 100644 --- a/src/commands/Ondemand/GetRSSUpdates.js +++ b/src/commands/Ondemand/GetRSSUpdates.js @@ -16,7 +16,7 @@ class GetRSSUpdates extends Command { async run(message, ctx) { const feedUrl = feeds.filter(f => f.key === `forum.updates.${ctx.platform}`)[0].url; - const matchingFeeds = this.bot.feedNotifier.feeder.list.filter(f => f.url === feedUrl); + const matchingFeeds = this.bot.feeder.list.filter(f => f.url === feedUrl); const updates = matchingFeeds && matchingFeeds[0] && matchingFeeds[0].items && matchingFeeds[0].items[0] ? [...matchingFeeds[0].items] diff --git a/src/embeds/RSSEmbed.js b/src/embeds/RSSEmbed.js index 4e7f6dc15..afc077d75 100644 --- a/src/embeds/RSSEmbed.js +++ b/src/embeds/RSSEmbed.js @@ -3,8 +3,7 @@ const BaseEmbed = require('./BaseEmbed.js'); const { chunkify, markdinate } = require('../CommonFunctions'); - -const escapeReserved = str => str.replace(/[\(\)\?\+\.]/ig, '\\$1'); +const logger = require('../Logger'); /** * Generates daily deal embeds @@ -18,8 +17,8 @@ class RSSEmbed extends BaseEmbed { constructor(feedItem, feed) { super(); // clean up description, falling back to an empty string - let strippedDesc = markdinate(escapeReserved((feedItem.description || '\u200B') - .replace(/\<\\?string\>/ig, ''))); + let strippedDesc = markdinate((feedItem.description || '\u200B') + .replace(/\<\\?string\>/ig, '')); const firstLine = strippedDesc.split('\n')[0].replace(/\*\*/g, ''); if (feedItem.title.includes(firstLine)) { @@ -28,21 +27,26 @@ class RSSEmbed extends BaseEmbed { strippedDesc = tokens.join('\n'); } - const chunks = chunkify({ - string: strippedDesc, - maxLength: 1000, - breakChar: '\n', - checkTitle: true, - }); + try { + const chunks = chunkify({ + string: strippedDesc, + maxLength: 1000, + breakChar: '\n', + checkTitle: true, + }); - if (chunks) { - [strippedDesc] = chunks; - // strip the last title if it starts with a title - if (strippedDesc.endsWith('**')) { - strippedDesc = strippedDesc.replace(/\*\*(.*)\*\*$/g, ''); - } + if (chunks) { + [strippedDesc] = chunks; + // strip the last title if it starts with a title + if (strippedDesc.endsWith('**')) { + strippedDesc = strippedDesc.replace(/\*\*(.*)\*\*$/g, ''); + } - this.description = strippedDesc; + this.description = strippedDesc; + } + } catch (e) { + logger.error(e); + logger.debug(strippedDesc, 'WS'); } this.url = feedItem.link; diff --git a/src/embeds/TwitchEmbed.js b/src/embeds/TwitchEmbed.js index 73ffe251a..ed5c2345b 100644 --- a/src/embeds/TwitchEmbed.js +++ b/src/embeds/TwitchEmbed.js @@ -7,21 +7,40 @@ const BaseEmbed = require('./BaseEmbed.js'); */ class TwitchEmbed extends BaseEmbed { /** - * @param {HelixStream} streamData - a stream result from twitch api - * @param {HelixUser} userData - a user result from twitch api + * @param {Object} streamData - a stream result from twitch api */ - constructor(streamData, userData) { + constructor(streamData) { super(); this.title = streamData.title; - if (userData) { + this.url = `https://www.twitch.tv/${streamData.user_login}`; + + this.image = { + url: streamData.thumbnail_url + .replace('{width}', '1280') + .replace('{height}', '720'), + }; + + this.color = 6570405; + this.footer = { + text: 'Live @', + icon_url: 'https://i.imgur.com/urcKWLO.png', + }; + + if (streamData.user) { this.author = { - name: streamData.userDisplayName, - icon_url: userData.profilePictureUrl, + name: streamData.user.display_name, + icon_url: streamData.user.profile_image_url, }; - this.image = { - url: streamData.thumbnailUrl.replace('{width}', '580').replace('{height}', '326'), + + this.description = streamData.user.description; + } + + if (streamData.game) { + this.thumbnail = { + url: streamData.game.box_art_url + .replace('{width}', '288') + .replace('{height}', '384'), }; - this.url = `https://www.twitch.tv/${userData.name}`; } } } diff --git a/src/notifications/Broadcaster.js b/src/notifications/Broadcaster.js index 1e27dbd89..0ba4890f6 100644 --- a/src/notifications/Broadcaster.js +++ b/src/notifications/Broadcaster.js @@ -57,7 +57,7 @@ class Broadcaster { ? this.workerCache.getKey(`${type}:${platform}`) : await this.settings.getAgnosticNotifications(type, platform, items); if (!channels.length) { - logger.debug(`No channels on ${platform} tracking ${type}... continuing`); + logger.silly(`No channels on ${platform} tracking ${type}... continuing`, 'WS'); return; } diff --git a/src/notifications/TwitchNotifier.js b/src/notifications/TwitchNotifier.js deleted file mode 100644 index ef79544fa..000000000 --- a/src/notifications/TwitchNotifier.js +++ /dev/null @@ -1,116 +0,0 @@ -'use strict'; - -const { ApiClient } = require('twitch'); -const WebHookListener = require('twitch-webhooks').default; - -const TwitchEmbed = require('../embeds/TwitchEmbed'); -const Broadcaster = require('./Broadcaster'); -const { platforms } = require('../CommonFunctions'); -const logger = require('../Logger'); - -require('colors'); - -/** - * Watches for Twitch go-lives and broadcasts them - */ -class TwitchNotifier { - constructor({ - client, settings, messageManager, workerCache, - }) { - this.broadcaster = new Broadcaster({ - client, - settings, - messageManager, - workerCache, - }); - - this.lastStartedAtTime = null; - - if (process.env.TWITCH_CLIENT_ID && process.env.TWITCH_CLIENT_SECRET) { - const id = process.env.TWITCH_CLIENT_ID; - const secret = process.env.TWITCH_CLIENT_SECRET; - this.client = ApiClient.withClientCredentials(id, secret); - } else { - logger.debug('[Twitch] Cannot initialize Twitch Notifier... invalid credentials'); - } - - this.subs = {}; - } - - async start() { - if (!this.client) return; - - try { - this.listener = await WebHookListener.create(this.client, { - host: process.env.TWITCH_HOST || 'localhost', - port: 8090, - reverseProxy: { port: 443, ssl: true }, - }); - - this.listener.listen(); - - await this.listenToStreams(); - } catch (e) { - logger.error(`initialzation error: ${e.message}`); - return; - } - logger.info(`[${'Twitch'.purple}] Ready`); - } - - /** - * Set up subscription for twitch user id - * @param {string} sub twitch user id - * @returns {Promise} - */ - async subscribe(sub) { - const user = await this.client.helix.users.getUserByName(sub); - - const subscription = this.listener.subscribeToStreamChanges(user.id, async (stream) => { - if (stream) { - const twitchEmbed = new TwitchEmbed(stream, user); - let id = `${sub}.live`; - // add warframe type filtering for ids... - if (sub === 'warframe') { - if (stream.title.includes('Home Time') || stream.title.includes('Prime Time')) { - id = `${sub}.primetime.live`; - } else if (stream.title.includes('Devstream')) { - id = `${sub}.devstream.live`; - } else { - id = `${sub}.other.live`; - } - } - - // broadcast it! - platforms.forEach((platform) => { - this.broadcaster.broadcast(twitchEmbed, platform, id); - }); - } - }); - this.subs[sub] = subscription; - logger.debug(`[Twitch] listening for '${sub}' stream changes...`); - } - - /** - * Set up listening to streams - */ - async listenToStreams() { - if (!(this.client && this.listener)) return; - - const subs = require('../resources/twitch.json'); - - const subPromises = []; - - subs.forEach((sub) => { - subPromises.push(this.subscribe(sub)); - }); - - await Promise.all(subPromises); - - process.on('exit', () => { - Object.entries(subs).forEach(subscription => subscription.stop()); - logger.debug('[Twitch] Subscriptions unregistered...'); - }); - } -} - -module.exports = TwitchNotifier; diff --git a/src/notifications/Worker.js b/src/notifications/Worker.js index 7858ffa4f..659eef51f 100644 --- a/src/notifications/Worker.js +++ b/src/notifications/Worker.js @@ -6,14 +6,14 @@ require('colors'); const Notifier = require('./Notifier'); const FeedsNotifier = require('./FeedsNotifier'); -const TwitchNotifier = require('./TwitchNotifier'); +const TwitchNotifier = require('./twitch/TwitchNotifier'); const WorldStateCache = require('../WorldStateCache'); const MessageManager = require('../settings/MessageManager'); const Rest = require('../tools/RESTWrapper'); const Database = require('../settings/Database'); -const { logger, platforms } = require('./NotifierUtils'); +const { logger } = require('./NotifierUtils'); const { games } = require('../CommonFunctions'); const cachedEvents = require('../resources/cachedEvents'); @@ -109,6 +109,7 @@ class Worker { deps.client = rest; deps.worldStates = this.worldStates; deps.timeout = timeout; + deps.activePlatforms = activePlatforms; await rest.init(); await deps.settings.init(); @@ -133,10 +134,10 @@ class Worker { await this.notifier.start(); - if (logger.isLoggable('debug')) { + if (logger.isLoggable('DEBUG')) { rest.controlMessage({ embeds: [{ - description: `Worker ready on ${platforms}`, + description: `Worker ready on ${activePlatforms}`, color: 0x2B90EC, }], }); diff --git a/src/notifications/twitch/TwitchClient.js b/src/notifications/twitch/TwitchClient.js new file mode 100644 index 000000000..4217ec86a --- /dev/null +++ b/src/notifications/twitch/TwitchClient.js @@ -0,0 +1,194 @@ +'use strict'; + +require('colors'); +const fetch = require('node-fetch'); +const Cache = require('flat-cache'); +const Job = require('cron').CronJob; + +const logger = require('../../Logger'); + +const urls = { + helix: 'https://api.twitch.tv/helix', + token: 'https://id.twitch.tv/oauth2/token', +}; + +const forceHydrate = (process.argv[2] || '').includes('--hydrate'); + +/** + * Twitch Helix API helper ("New Twitch API"). + * + * All credit to https://github.com/roydejong/timbot for composition and structure + */ +class TwitchClient { + static #tokenCache = Cache.load('accessToken', + require('path').resolve('.cache')); + + static get accessToken() { + return this.#tokenCache.getKey('token'); + } + + /** + * Sets the access token in the cache + * @param {string} token twitch access token to cache + */ + static set #accessToken (token) { + this.#tokenCache.setKey('token', token); + this.#tokenCache.save(true); + } + + static get refreshToken() { + return this.#tokenCache.getKey('refresh'); + } + + /** + * Sets the refresh token in the cache + * @param {string} token twitch refresh token to cache + */ + static set #refreshToken (token) { + this.#tokenCache.setKey('refresh', token); + this.#tokenCache.save(true); + } + + static get requestOptions() { + return { + baseURL: urls.helix, + headers: { + 'Client-ID': process.env.TWITCH_CLIENT_ID, + Authorization: `Bearer ${this.accessToken}`, + }, + }; + } + + /** + * Handle twitch API errors + * @param {Error} err error reply + */ + static handleApiError(err) { + const res = err.response || { }; + + if (res.data && res.data.message) { + logger.error(`API request failed with Helix error:\n${res.data.message}\n(${res.data.error}/${res.data.status})`, 'TwitchApi'); + } else { + logger.error(`API request failed with error: ${err.message || err}`, 'TwitchApi'); + } + } + + /** + * Get Data from the Twitch API + * @param {string} path path to request + * @param {string} params query params + * @private + * @static + * @returns {Promise>} + */ + static async #apiGet (path, params) { + if (!this.accessToken) await this.hydrateToken(); + if (!this.accessToken && !this.refreshToken) return []; + + const url = `${this.requestOptions.baseURL}/${path}?${params}`; + try { + const res = await fetch(url, { + headers: { + 'Client-ID': process.env.TWITCH_CLIENT_ID, + Authorization: `Bearer ${this.accessToken}`, + }, + }); + + if (res.ok) { + const data = await res.json(); + return data.data || []; + } + logger.error(res.statusText); + return []; + } catch (e) { + this.handleApiError(e); + return []; + } + } + + /** + * Hydrate cache with a new token. + * @type {Array} + */ + static async hydrateToken() { + if (!this.refreshToken || forceHydrate) { + if (this.refreshToken && this.refreshToken.includes('Invalid refresh token')) { + this.#tokenCache.removeKey('refresh'); + } + + const atParams = [ + { key: 'client_id', val: process.env.TWITCH_CLIENT_ID }, + { key: 'client_secret', val: process.env.TWITCH_CLIENT_SECRET }, + { key: 'grant_type', val: 'client_credentials' }, + ]; + + const res = await fetch(`${urls.token}?${atParams + .map(({ key, val }) => `${key}=${val}`).join('&')}`, { method: 'POST' }); + + if (res.ok) { + const initAccessToken = (await res.json()).access_token; + this.#refreshToken = initAccessToken; + this.#accessToken = initAccessToken; + return; + } + logger.error(`error refreshing refresh token: ${res.statusText}`, 'TWITCH'); + } + + const params = [ + { key: 'client_id', val: process.env.TWITCH_CLIENT_ID }, + { key: 'client_secret', val: process.env.TWITCH_CLIENT_SECRET }, + { key: 'grant_type', val: 'refresh_token' }, + { key: 'refresh_token', val: this.refreshToken }, + ]; + const url = `${urls.token}?${params.map(({ key, val }) => `${key}=${val}`).join('&')}`; + + try { + const res = await fetch(url, { method: 'post' }); + + if (res.ok) { + const token = (await res.json()).access_token; + logger.info(token, 'TWITCH'); + this.#accessToken = token; + } else { + logger.error(`error retrieving refreshing token: ${res.statusText}`, 'TWITCH'); + logger.debug(url); + } + } catch (e) { + logger.error(e, 'TWITCH'); + } + } + + static #refreshJob = new Job('0 0 */3 * * *', this.hydrateToken.bind(this), null, true); + + /** + * Fetch stream data + * @param {Array} channels list of channels to fetch + * @returns {Promise>} array of stream data + */ + static async fetchStreams(channels) { + const params = channels.map(c => `user_login=${c}`).join('&'); + return TwitchClient.#apiGet('streams', params); + } + + /** + * Fetch Twitch Users + * @param {Array} channelNames list of channels to fetch + * @returns {Promise>} array of stream data + */ + static async fetchUsers(channelNames) { + const params = channelNames.map(c => `login=${c}`).join('&'); + return TwitchClient.#apiGet('users', params); + } + + /** + * Get Game data per gameId + * @param {Array} gameIds Twitch game identifiers + * @returns {Promise>} array of game data + */ + static async fetchGames(gameIds) { + const params = gameIds.map(c => `id=${c}`).join('&'); + return TwitchClient.#apiGet('games', params); + } +} + +module.exports = TwitchClient; diff --git a/src/notifications/twitch/TwitchMonitor.js b/src/notifications/twitch/TwitchMonitor.js new file mode 100644 index 000000000..70a2caa81 --- /dev/null +++ b/src/notifications/twitch/TwitchMonitor.js @@ -0,0 +1,289 @@ +'use strict'; + +const moment = require('moment'); +const Cache = require('flat-cache'); +require('colors'); +const EventEmitter = require('events'); + +const TwitchApi = require('./TwitchClient'); +const logger = require('../../Logger'); +const channels = require('../../resources/twitch.json'); + +const haveEqualValues = (a, b) => { + if (a.length !== b.length) { + return false; + } + + a.sort(); + b.sort(); + + for (let i = 0; i < a.length; i += 1) { + if (a[i] !== b[i]) { + return false; + } + } + + return true; +}; + +const refreshRate = Number.parseInt(process.env.TWITCH_REFRESH || 60000, 10); + +const forceHydrate = (process.argv[2] || '').includes('--hydrate'); + +class TwitchMonitor extends EventEmitter { + #userDb; + + #gameDb; + + #statesDb; + + #pendingUserRefresh = true; + + #pendingGameRefresh; + + #watchingGameIds; + + #lastUserRefresh; + + #lastGameRefresh; + + #userData; + + #gameData; + + #activeStreams; + + #streamData; + + #channelLiveCallbacks; + + #channelOfflineCallbacks; + + static #MIN_POLL_INTERVAL_MS = 30000; + + constructor() { + super(); + this.#userDb = Cache.load('users'); + this.#gameDb = Cache.load('games'); + this.#statesDb = Cache.load('states') || {}; + this.#pendingUserRefresh = false; + this.#pendingGameRefresh = false; + this.#watchingGameIds = []; + this.#lastUserRefresh = this.#userDb.getKey('last-update'); + this.#lastGameRefresh = moment(); + this.#userData = this.#userDb.getKey('user-list') || {}; + this.#gameData = this.#gameDb.getKey('game-list') || {}; + this.#activeStreams = []; + this.#streamData = this.#statesDb.getKey('all') || {}; + this.#channelLiveCallbacks = []; + this.#channelOfflineCallbacks = []; + } + + async start() { + if (!channels.length) { + logger.warn('No channels configured', 'TM'); + return; + } + + if (forceHydrate) { + await TwitchApi.hydrateToken(); + } + + // Configure polling interval + let checkIntervalMs = refreshRate; + if (Number.isNaN(checkIntervalMs) || checkIntervalMs < TwitchMonitor.MIN_POLL_INTERVAL_MS) { + // Enforce minimum poll interval to help avoid rate limits + checkIntervalMs = TwitchMonitor.MIN_POLL_INTERVAL_MS; + } + setInterval(() => { + this.refresh('Periodic refresh'); + }, checkIntervalMs + 1000); + + // Immediate refresh after startup + setTimeout(() => { + this.refresh('Initial refresh after start-up'); + }, 1000); + + // Ready! + logger.debug(`(${checkIntervalMs}ms interval) Configured stream status polling for channels: ${channels.join(', ')}`, 'TM'); + } + + async refresh(reason) { + const now = moment(); + logger.silly(`Refreshing now (${reason || 'No reason'})`, 'TM'); + + // Refresh all users periodically + if (this.#lastUserRefresh === null || now.diff(moment(this.#lastUserRefresh), 'minutes') >= 10) { + this.#pendingUserRefresh = true; + try { + this.#handleUserList(await TwitchApi.fetchUsers(channels)); + } catch (err) { + logger.warn(`Error in users refresh: ${err.message || err}`, 'TM'); + } finally { + if (this.#pendingUserRefresh) { + this.#pendingUserRefresh = false; + this.refresh('Got Twitch users, need to get streams'); + } + } + } + + // Refresh all games if needed + if (this.#pendingGameRefresh) { + try { + if (this.#watchingGameIds.length) { + this.#handleGameList(await TwitchApi.fetchGames(this.#watchingGameIds)); + } + } catch (err) { + logger.warn(`Error in games refresh ${err.message || err}`, 'TM'); + } finally { + if (this.#pendingGameRefresh) { + this.#pendingGameRefresh = false; + } + } + } + + // Refresh all streams + try { + await this.#handleStreamList(await TwitchApi.fetchStreams(channels)); + } catch (err) { + logger.warn(`Error in streams refresh: ${err.message || err}`, 'TM'); + logger.error(err, 'TM'); + } + } + + #handleUserList (users) { + const namesSeen = []; + + users.forEach((user) => { + const prevUserData = this.#userData[user.id] || { }; + this.#userData[user.id] = { ...prevUserData, ...user }; + + namesSeen.push(user.display_name); + }); + + if (namesSeen.length) { + logger.silly(`Updated user info: ${namesSeen.join(', ')}`, 'TM'); + } + + this.#lastUserRefresh = moment(); + + this.#userDb.setKey('last-update', this.#lastUserRefresh); + this.#userDb.setKey('user-list', this.#userData); + this.#userDb.save(true); + } + + #handleGameList (games) { + const gotGameNames = []; + + games.forEach((game) => { + const gameId = game.id; + + const prevGameData = this.#gameData[gameId] || { }; + this.#gameData[gameId] = { ...prevGameData, ...game }; + + gotGameNames.push(`${game.id} → ${game.name}`); + }); + + if (gotGameNames.length) { + logger.silly(`Updated game info: ${gotGameNames.join(', ')}`, 'TM'); + } + + this.#lastGameRefresh = moment(); + + this.#gameDb.setKey('last-update', this.#lastGameRefresh); + this.#gameDb.setKey('game-list', this.#gameData); + this.#gameDb.save(true); + } + + async #handleStreamList (streams) { + // Index channel data & build list of stream IDs now online + const nextOnlineList = []; + const nextGameIdList = []; + + for (const stream of streams) { + const channelName = stream.user_name.toLowerCase(); + + if (stream.type === 'live') { + nextOnlineList.push(channelName); + } + + // logger.debug(`${channelName} last seen as ${this.#statesDb.getKey(channelName)}`); + + if (typeof this.#statesDb.getKey(channelName) === 'undefined') { + this.#statesDb.setKey(channelName, stream.type); + this.#statesDb.save(true); + } + + const userDataBase = this.#userData[stream.user_id] || { }; + const prevStreamData = this.#streamData[channelName] || { }; + + this.#streamData[channelName] = { ...userDataBase, ...prevStreamData, ...stream }; + this.#streamData[channelName].game = (stream.game_id + && this.#gameData[stream.game_id]) || null; + this.#streamData[channelName].user = userDataBase; + + if (this.#statesDb.getKey(channelName) !== 'live' && stream.type === 'live') { + // logger.debug(`${channelName} has gone online`); + this.#handleChannelLiveUpdate(this.#streamData[channelName], true); + } + + if (stream.game_id) { + nextGameIdList.push(stream.game_id); + } + + this.#statesDb.setKey(channelName, stream.type); + this.#statesDb.save(true); + } + this.#statesDb.setKey('all', this.#streamData); + + for (const channel of channels) { + if (!streams.find(s => s.user_login === channel) + && this.#statesDb.getKey(channel) === 'live') { + // logger.debug(`${channel} has gone offline`); + this.#handleChannelLiveUpdate( + this.#streamData[channel] || { type: 'offline', user_login: channel }, false, + ); + this.#statesDb.setKey(channel, 'offline'); + } + } + this.#statesDb.save(true); // save after all are updated + + if (!haveEqualValues(this.#watchingGameIds, nextGameIdList)) { + // We need to refresh game info + this.#watchingGameIds = nextGameIdList; + this.#pendingGameRefresh = true; + this.refresh('Need to request game data'); + } + } + + #handleChannelLiveUpdate (streamData, isOnline) { + let success = true; + + try { + if (isOnline) { + this.emit('live', streamData); + } else { + this.emit('offline', streamData); + } + } catch (err) { + logger.warn('Event emit failed.', 'TM'); + success = false; + } + return success; + } + + async #spotLoadGame (gameId) { + this.#handleGameList(await TwitchApi.fetchGames([gameId])); + return this.#gameData[gameId]; + } + + async #spotLoadStream (channel) { + return this.#handleStreamList(await TwitchApi.fetchStreams([channel])); + } + + async #spotLoadUser (user) { + return this.#handleUserList(await TwitchApi.fetchUsers([user])); + } +} + +module.exports = TwitchMonitor; diff --git a/src/notifications/twitch/TwitchNotifier.js b/src/notifications/twitch/TwitchNotifier.js new file mode 100644 index 000000000..d6cfe96d3 --- /dev/null +++ b/src/notifications/twitch/TwitchNotifier.js @@ -0,0 +1,70 @@ +'use strict'; + +const TwitchEmbed = require('../../embeds/TwitchEmbed'); +const TwitchMonitor = require('./TwitchMonitor'); + +const Broadcaster = require('../Broadcaster'); +const logger = require('../../Logger'); + +require('colors'); + +/** + * Watches for Twitch go-lives and broadcasts them + */ +class TwitchNotifier { + #monitor; + + #broadcaster; + + #activePlatforms; + + constructor({ + client, settings, messageManager, workerCache, activePlatforms, + }) { + this.#broadcaster = new Broadcaster({ + client, + settings, + messageManager, + workerCache, + }); + this.#monitor = new TwitchMonitor(); + this.#activePlatforms = activePlatforms; + + this.enabled = true; + } + + start() { + try { + this.#monitor.start(); + } catch (e) { + logger.error(`initialzation error: ${e.message}`, 'TWITCH'); + return; + } + logger.info('Ready', 'Twitch'); + + this.#monitor.on('live', (streamData) => { + if (this.enabled) { + const embed = new TwitchEmbed(streamData); + let id = `${streamData.user_login}.live`; + // add warframe type filtering for ids... + if (streamData.user_login === 'warframe') { + if (streamData.title.includes('Devstream')) { + id = `${streamData.user_login}.devstream.live`; + } else if (streamData.title.includes('Home Time') + || streamData.title.includes('Prime Time') + || streamData.title.includes('Working From Home') + || streamData.title.includes('Community Stream')) { + id = `${streamData.user_login}.primetime.live`; + } else { + id = `${streamData.user_login}.other.live`; + } + } + for (const platform of this.#activePlatforms) { + this.#broadcaster.broadcast(embed, platform, id); + } + } + }); + } +} + +module.exports = TwitchNotifier; diff --git a/src/resources/trackables.json b/src/resources/trackables.json index 27d9fb7bb..ef5d34ecb 100644 --- a/src/resources/trackables.json +++ b/src/resources/trackables.json @@ -178,6 +178,10 @@ "twitch": [ "warframe.devstream.live", "warframe.primetime.live", - "warframe.other.live" + "warframe.other.live", + "sherpa.live", + "joeyzerotv.live", + "de_rebecca.live", + "de_steve.live" ] } diff --git a/src/resources/twitch.json b/src/resources/twitch.json index 984135e45..6e7405382 100644 --- a/src/resources/twitch.json +++ b/src/resources/twitch.json @@ -1,3 +1,3 @@ [ - "warframe" + "warframe", "sherpa", "joeyzerotv", "de_rebecca", "de_steve" ] \ No newline at end of file