// Copyright 2018-2020 Campbell Crowley. All rights reserved.
// Author: Campbell Crowley (dev@campbellcrowley.com)
const fs = require('fs');
const http = require('http');
const https = require('https');
const httpProxy = require('http-proxy');
const auth = require('../../auth.js');
const crypto = require('crypto');
const sIOClient = require('socket.io-client');
const SubModule = require('../subModule.js');
delete require.cache[require.resolve('./WebUserData.js')];
const WebUserData = require('./WebUserData.js');
delete require.cache[require.resolve('./ApiEndpoint.js')];
const ApiEndpoint = require('./ApiEndpoint.js');
delete require.cache[require.resolve('./ApiRequestBody.js')];
const ApiRequestBody = require('./ApiRequestBody.js');
const basicAuth = 'Basic ' +
(auth.commandUsername + ':' + auth.commandPassword).toString('base64');
/**
* @classdesc Handles receiving webhooks and other API requests from external
* services.
* @class
* @augments SubModule
*/
class WebApi extends SubModule {
/**
* Creates SubModule instance.
*/
constructor() {
super();
this.myName = 'WebAPI';
/**
* @description Web server listening for requests, fires
* {@link WebCommands._handler} on requests.
*
* @private
* @type {?http.Server}
*/
this._app = null;
/**
* @description Internal proxy server for forwarding api requests to other
* endpoints.
* @private
*/
this._proxy = httpProxy.createProxyServer({ws: true, xfwd: false});
/**
* @description Data representing available API endpoints, and where to
* redirect data.
*
* @private
* @type {object.<ApiEndpoint>}
* @constant
* @default
*/
this._endpoints = {
'hg': new ApiEndpoint('hg', 'http://localhost:8011', 'hg/'),
'hg-dev': new ApiEndpoint('hg-dev', 'http://localhost:8013', 'dev/hg/'),
'account':
new ApiEndpoint('account', 'http://localhost:8014', 'account/'),
'account-dev': new ApiEndpoint(
'account-dev', 'http://localhost:8015', 'dev/account/'),
'settings':
new ApiEndpoint('settings', 'http://localhost:8020', 'control/'),
'settings-dev': new ApiEndpoint(
'settings-dev', 'http://localhost:8021', 'dev/control/'),
};
/**
* @description File storing website rate limit specifications.
*
* @private
* @type {string}
* @constant
* @default
*/
this._rateLimitFile = './save/webRateLimits.json';
/**
* @description History of requests for rate limiting purposes, mapped by
* user token.
*
* @private
* @type {object}
* @constant
* @default
*/
this._rateHistory = {};
/**
* Object storing parsed rate limit info from {@link WebApi~_rateLimitFile}.
*
* @private
* @type {object}
* @default
*/
this._rateLimits = {
commands: {},
groups: {global: {num: 2, delta: 2}},
};
this._handler = this._handler.bind(this);
}
/** @inheritdoc */
initialize() {
if (this.common.isSlave) {
this.error(
'This submodule will not work on a slave shard. Refusing to start.');
return;
}
this._app = http.createServer(this._handler);
setTimeout(() => {
this._app.listen(this.common.isRelease ? 8018 : 8019, '127.0.0.1');
});
this._app.on('error', (err) => {
if (err.code === 'EADDRINUSE') {
this.debug(
'Webhooks failed to bind to port because it is in use. (' +
err.port + ')');
this.shutdown(true);
} else {
this.error('Webhooks failed to bind to port for unknown reason.', err);
}
});
this._updateRateLimits();
fs.watchFile(this._rateLimitFile, {persistent: false}, (curr, prev) => {
if (curr.mtime == prev.mtime) return;
this.debug('Re-reading rate limits from file');
this._updateRateLimits();
});
}
/** @inheritdoc */
shutdown() {
fs.unwatchFile(this._rateLimitFile);
if (this._app) {
this._app.close();
this._app = null;
}
}
/**
* @description Get the host data for requesting game information from Twitch.
* @private
* @static
* @returns {object} Default header information.
*/
static get _fetchGameHost() {
return {
protocol: 'https:',
host: 'api.twitch.tv',
path: '/helix/games?id=',
method: 'GET',
headers: {
'User-Agent': require('../common.js').ua,
'Client-ID': auth.twitchID,
},
};
}
/**
* Handler for all http requests.
*
* @private
* @param {http.IncomingMessage} req The client's request.
* @param {http.ServerResponse} res Our response to the client.
*/
_handler(req, res) {
const ip = req.headers['x-forwarded-for'] || req.connection.remoteAddress ||
'ERROR';
const url = req.url.replace(/^\/www.spikeybot.com(?:\/dev)?/, '');
if (url.startsWith('/api')) {
if (url.startsWith('/api/public')) {
this._publicApiRequest(req, res, url, ip);
} else {
this._apiRequest(req, res, url, ip);
}
} else if (url === '/webhook/twitch/') {
if (req.method === 'GET') {
this._twitchConfirmation(req, res, url, ip);
} else if (req.method === 'POST') {
this._twitchWebhook(req, res, url, ip);
} else {
res.writeHead(405);
res.end();
this.common.log(
'Requested endpoint with invalid method: ' + req.method + ' ' +
req.url + ' ' + url + ' ' + req.headers['queries'],
ip);
}
} else if (req.method !== 'POST') {
res.writeHead(405);
res.end();
this.common.log(
'Requested endpoint with invalid method: ' + req.method + ' ' +
req.url + ' ' + url,
ip);
} else if (url.startsWith('/webhook/botstart')) {
this.common.logDebug('Bot start webhook request: ' + req.url, ip);
let content = '';
req.on('data', (chunk) => content += chunk);
req.on('end', () => {
this.debug('Bot start webhook content: ' + content);
res.writeHead(204);
res.end();
});
} else if (!url.startsWith('/webhook')) {
res.writeHead(501);
res.end();
this.common.log('Requested non-existent endpoint: ' + req.url, ip);
} else if (req.headers.authorization !== basicAuth) {
this.common.error(
'Requested webhook with incorrect authorization header: ' +
req.headers.authorization,
ip);
res.writeHead(401);
res.end();
} else {
let content = '';
req.on('data', (chunk) => content += chunk);
req.on('end', () => {
console.log(content);
res.writeHead(204);
res.end();
});
}
}
/**
* @description Handles requests to the public (unauthenticated) API endpoint.
*
* @private
* @param {http.IncomingMessage} req Client request.
* @param {http.ServerResponse} res Server response.
* @param {string} url The requested url. Generally a similar or slightly
* modified version of `req.url`.
* @param {string} ip IP for logging purposes.
*/
_publicApiRequest(req, res, url, ip) {
const endpoints = [
[
'/api/public/patreon-campaign',
(...a) => this._patreonCampaignEndpoint(...a),
],
[
'/api/public/shard-status-history',
(...a) => this._shardStatusHistoryEndpoint(...a),
],
];
const acceptable = endpoints.find((el) => url.startsWith(el[0]));
if (!acceptable) {
this.common.logDebug(`Public API Unknown: ${url}`, ip);
res.writeHead(404);
res.end('404: Not Found');
return;
}
const cmd = acceptable[0].match(/\/api\/public\/([^/?&#]+)/);
const rateInfo = this._checkRateLimit(cmd, ip);
res.setHeader('X-RateLimit-Limit', rateInfo.limit);
res.setHeader('X-RateLimit-Remaining', rateInfo.remaining);
res.setHeader('X-RateLimit-Bucket', rateInfo.group);
if (rateInfo.exceeded) {
res.writeHead(429);
res.end();
return;
}
if (!acceptable[1]) {
res.writeHead(500);
res.end('500: Internal Server Error (No Handler)');
} else {
acceptable[1](req, res, url, ip);
}
}
/**
* @description Fetches Patreon campaign information.
*
* @private
* @param {http.IncomingMessage} req Client request.
* @param {http.ServerResponse} res Server response.
* @param {string} url The requested url. Generally a similar or slightly
* modified version of `req.url`.
* @param {string} ip IP for logging purposes.
*/
_patreonCampaignEndpoint(req, res, url, ip) {
this.debug(url, ip);
if (!this.bot.patreon || !this.bot.patreon.fetchCampaign) {
res.writeHead(503);
res.end('503: Service Unavailable');
return;
}
this.bot.patreon.fetchCampaign((err, data) => {
if (err) {
this.error('Failed to fetch Patreon campaign info!');
console.log(err);
res.writeHead(500);
res.end('500: Internal Server Error');
return;
}
res.writeHead(200);
res.end(JSON.stringify(data));
});
}
/**
* @description Fetches shard status history information.
*
* @private
* @param {http.IncomingMessage} req Client request.
* @param {http.ServerResponse} res Server response.
* @param {string} url The requested url. Generally a similar or slightly
* modified version of `req.url`.
* @param {string} ip IP for logging purposes.
*/
_shardStatusHistoryEndpoint(req, res, url, ip) {
this.debug(url, ip);
if (!this.common.isMaster) {
res.writeHead(503);
res.end('503: Service Unavailable');
return;
}
if (this.common.isRelease) {
this._proxy.web(req, res, {target: 'http://localhost:8024'});
} else {
this._proxy.web(req, res, {target: 'http://localhost:8025'});
}
}
/**
* @description Handles requests to the API endpoint.
*
* @private
* @param {http.IncomingMessage} req Client request.
* @param {http.ServerResponse} res Server response.
* @param {string} url The requested url. Generally a similar or slightly
* modified version of `req.url`.
* @param {string} ip IP for logging purposes.
*/
_apiRequest(req, res, url, ip) {
const match = url.match(
/^\/api\/(?<endpoint>[^/]+){1}\/(?<cmd>[^/]+){1}(?:\/(?<args>.*))?$/);
if (!match || !match.groups.endpoint || !match.groups.cmd ||
!this._endpoints[match.groups.endpoint]) {
this.common.logDebug(`API Unknown: ${url} ${JSON.stringify(match)}`, ip);
res.writeHead(404);
res.end('404: Not Found');
return;
}
const token = req.headers.authorization;
if (!token || token.length == 0) {
this.common.logDebug(`API NOTOKEN: ${url}`, ip);
res.writeHead(401);
res.end('401: Unauthorized');
return;
}
const rateInfo = this._checkRateLimit(match.groups.cmd, token);
res.setHeader('X-RateLimit-Limit', rateInfo.limit);
res.setHeader('X-RateLimit-Remaining', rateInfo.remaining);
res.setHeader('X-RateLimit-Bucket', rateInfo.group);
if (rateInfo.exceeded) {
res.writeHead(429);
res.end();
return;
}
const ep = this._endpoints[match.groups.endpoint];
// const args = match.groups.args && match.grous.args.split('/');
let reqData = null;
let userData = null;
const self = this;
const checkDone = function() {
if (reqData && userData && res) {
self._apiReady(userData, reqData, res, ip);
}
};
let body = '';
req.on('data', (chunk) => body += chunk);
req.on('end', () => {
try {
reqData = ApiRequestBody.from(JSON.parse(body), match.groups.cmd);
reqData.endpoint = ep;
checkDone();
} catch (err) {
this.common.error('Failed to parse request body: ' + url, ip);
res.writeHead(400);
res.end('400: Bad Request');
return;
}
});
const toSend = global.sqlCon.format(
'SELECT id FROM Discord WHERE apiToken=?', [token]);
global.sqlCon.query(toSend, (err, rows) => {
if (err) {
this.common.error('SQL query for user API token failed.', ip);
console.error(err);
res.writeHead(500);
res.end('500: Internal Server Error');
return;
}
if (!rows || rows.length === 0 || !rows[0].id) {
this.common.logDebug(`API BADTOKEN: ${url}`, ip);
res.writeHead(401);
res.end('401: Unauthorized');
return;
}
userData = new WebUserData(rows[0].id);
userData.apiRequest = true;
checkDone();
});
}
/**
* @description All necessary request data has been collected for an endpoint.
* Handle finally firing request internally.
*
* @private
* @param {WebUserData} userData The user data for the request.
* @param {ApiRequestBody} reqData Data sent in body of request to send to
* endpoint.
* @param {http.ServerResponse} res Our response to the api request.
* @param {string} ip Client IP for logging.
*/
_apiReady(userData, reqData, res, ip) {
// Technically the 503 (Service Unavailable) responses should probably be a
// 504 (Gateway Timeout) error, but for the sake of the API endpoint, the
// client will see this as the final server failing to process the request,
// which is semantically clearer to the user.
const socket = sIOClient(reqData.endpoint.host, {
path: reqData.endpoint.path,
extraHeaders: {'x-forwarded-for': ip},
reconnection: false,
timeout: 5000,
});
socket.on('connect', () => {
const reqTimeout = setTimeout(() => {
this.common.logDebug(
'No response from endpoint request: ' +
(reqData.cmd || 'NO COMMAND'),
ip);
socket.close();
res.writeHead(404);
res.end('404: Not Found');
}, 5000);
const ud = userData.serializable;
socket.emit(reqData.cmd, ud, ...reqData.args, (err, ...response) => {
clearTimeout(reqTimeout);
socket.close();
this.common.logDebug(
'Fullfilled API Request: ' + reqData.endpoint.name + ' (err: ' +
!!err + ', ' + reqData.cmd + ')');
if (err) {
res.writeHead(400, {'content-type': 'application/json'});
res.end(JSON.stringify({message: err}));
} else if (!response || response.length === 0) {
res.writeHead(204);
res.end();
} else {
res.writeHead(200, {'content-type': 'application/json'});
res.end(JSON.stringify({message: 'Success', args: response}));
}
});
});
socket.on('connect_error', (err) => {
this.common.logDebug(
'Failed to connect to endpoint: ' + reqData.endpoint.name, ip);
console.error(err);
res.writeHead(503);
res.end('503: Service Unavailable');
});
}
/**
* Check if this current connection or user is being rate limited.
*
* @private
* @param {string} cmd The command being run.
* @param {string} token The user's token used, equivalent of the user ID for
* purposes of rate limiting.
* @returns {{
* exceeded: boolean,
* limit: number,
* remaining: number,
* group: string
* }} Current rate limiting information for the given request and user data.
*/
_checkRateLimit(cmd, token) {
const now = Date.now();
const group = this._rateLimits.commands[cmd] || 'global';
if (!this._rateHistory[token]) this._rateHistory[token] = [];
const history = this._rateHistory[token];
history.push({time: now, cmd: cmd});
let num = 0;
for (let i = 0; i < history.length; i++) {
const g = this._rateLimits.commands[history[i].cmd] || 'global';
const limits =
this._rateLimits.groups[g] || this._rateLimits.groups['global'];
if (now - history[i].time > limits.delta * 1000) {
history.splice(i, 1);
i--;
} else if (group === g) {
num++;
}
}
if (history.length === 0) delete this._rateHistory[token];
const limit = this._rateLimits.groups[group].num;
return {
exceeded: num > limit,
limit: limit,
remaining: limit - num,
group: group,
};
}
/**
* Parse rate limits from file.
*
* @private
*/
_updateRateLimits() {
fs.readFile(this._rateLimitFile, (err, data) => {
if (err) {
this.error('Failed to read ' + this._rateLimitFile);
return;
}
try {
const parsed = JSON.parse(data);
if (!parsed) return;
this._rateLimits = parsed;
} catch (e) {
console.error(e);
}
});
}
/**
* @description Handle a request from Twitch to confirm adding a webhook to
* this endpoint.
* @private
* @param {http.IncomingMessage} req Client request.
* @param {http.ServerResponse} res Server response.
* @param {string} url The requested url. Generally a similar or slightly
* modified version of `req.url`.
* @param {string} ip IP for logging purposes.
*/
_twitchConfirmation(req, res, url, ip) {
const query = req.headers['queries'];
if (!query || query.length < 2) {
this.common.logDebug('400: ' + req.url + ' ' + query, ip);
res.writeHead(400);
res.end();
return;
}
const queries = {};
query.split('&').forEach((el) => {
const pair = el.split('=');
queries[pair[0]] = pair[1];
});
const decode =
queries['hub.topic'] && decodeURIComponent(queries['hub.topic']);
const match = decode && decode.match(/user_id=(\d+)/);
const id = match && match[1];
if (!id) {
this.common.logDebug(
'403 Invalid ID: ' + decode + ' ' + req.url + ' ' + query, ip);
res.writeHead(403);
res.end('403: Forbidden. Invalid ID.');
return;
}
const toSend = global.sqlCon.format(
'SELECT streamChangedState FROM TwitchUsers WHERE id=?', [id]);
global.sqlCon.query(toSend, (err, rows) => {
if (err) {
this.common.logDebug('500: ' + req.url + ' ' + query, ip);
this.common.error(
'Failed to check if attempting to Twitch confirm webhook: ' + id,
ip);
console.log(err);
res.writeHead(500);
res.end('500: Internal Server Error');
return;
}
if (rows && rows[0] && rows[0].streamChangedState >= 1) {
this.common.logDebug('200 Confirming: ' + req.url + ' ' + query, ip);
res.writeHead(200);
res.end(queries['hub.challenge']);
const toSend = global.sqlCon.format(
'UPDATE TwitchUsers SET streamChangedState=2, expiresAt=' +
'FROM_UNIXTIME(?) WHERE id=?',
[
Math.floor(Date.now() / 1000) + queries['hub.lease_seconds'] * 1,
id,
]);
global.sqlCon.query(toSend, (err) => {
if (err) {
this.common.error(
'Failed to update streamChangedState to confirmed: ' + id, ip);
console.log(err);
res.writeHead(500);
res.end('500: Internal Server Error');
return;
}
});
} else {
this.common.logDebug(
'403 Invalid ID: ' +
' ' + (rows && rows[0] && rows[0].streamChangedState) + ' ' +
req.url + ' ' + query,
ip);
res.writeHead(403);
res.end('403: Forbidden. Invalid ID.');
}
});
}
/**
* @description Handle a webhook event from Twitch.
* @private
* @param {http.IncomingMessage} req Client request.
* @param {http.ServerResponse} res Server response.
* @param {string} url The requested url. Generally a similar or slightly
* modified version of `req.url`.
* @param {string} ip IP for logging purposes.
*/
_twitchWebhook(req, res, url, ip) {
const query = req.headers['queries'];
const link = req.headers['link'];
this.common.log(
'Twitch Webhook: ' + req.url + ' ' + query + ' LINK:' + link, ip);
let content = '';
req.on('data', (c) => content += c);
req.on('end', () => {
const hmac = crypto.createHmac('sha256', auth.twitchSubSecret);
hmac.update(content);
const sig = `sha256=${hmac.digest('hex')}`;
const sigReq = req.headers['x-hub-signature'];
const verified = sig === sigReq;
if (!verified) {
this.common.error('Failed to verify webhook signature!');
console.error(
'Lengths:', req.headers['content-length'], content.length,
'Signatures:', sigReq, sig);
res.writeHead(403);
res.end('403: Forbidden');
return;
}
let parsed;
try {
parsed = JSON.parse(content);
} catch (err) {
this.common.logDebug(
'Failed to parse body of Twitch Webhook: ' + content, ip);
console.error(err);
res.writeHead(400);
res.end('400: Bad Request');
return;
}
const data = parsed.data && parsed.data[0];
if (parsed.data && parsed.data.length === 0) {
const user = link && link.match(/user_id=(\d+)/);
this.common.logDebug(
`Empty webhook from Twitch: ${content} User: (${user && user[1]})`,
ip);
res.writeHead(204);
res.end();
if (user && user[1]) this._twitchStreamEnd(user[1]);
return;
} else if (!data) {
this.common.logDebug(
'Invalid webhook body from Twitch: ' + content, ip);
res.writeHead(400);
res.end('400: Bad Request');
return;
}
this.common.logDebug('Twitch Webhook: ' + content, ip);
res.writeHead(204);
res.end();
this._fetchWebhookMetadata(data.user_id, data.game_id, content, data);
});
}
/**
* @description Fetch the necessary data for the parsed webhook request, then
* continue to sending alerts.
* @private
* @param {string} userId The Twitch user ID the request is for.
* @param {string} gameId The Twitch game ID the user is playing.
* @param {string} content Full content string from the webhook to
* re-broadcast to shards.
* @param {object} [data={}] Parsed content to object. Allows for additional
* metadata to be stored and to prevent duplicate alerts.
*/
_fetchWebhookMetadata(userId, gameId, content, data={}) {
let ids;
const numTotal = 3;
let numDone = 0;
const self = this;
const check = function() {
if (++numDone < numTotal) return;
self._twitchFinal(ids, content);
};
const insert = function(skip) {
let str = 'INSERT INTO TwitchStreams SET user=?, game=?, title=?';
if (startTime) str += ', startTime=FROM_UNIXTIME(?)';
const toSend = global.sqlCon.format(
str, [userId, gameId || '', title, new Date(startTime).getTime()]);
global.sqlCon.query(toSend, (err) => {
if (err) {
self.error('Failed to insert stream database info: ' + userId);
console.error(err);
}
if (!skip) check();
});
};
if (gameId && gameId.length > 0) {
this.fetchGameData(gameId, check);
} else {
check();
}
const botId = (this.client.user && this.client.user.id) ||
(this.common.isRelease ? '318552464356016131' : '422623712534200321');
const toSend = global.sqlCon.format(
'SELECT * FROM TwitchDiscord WHERE twitchId=? AND bot=?',
[userId, botId]);
global.sqlCon.query(toSend, (err, rows) => {
if (err) {
this.error('Failed to fetch TwitchDiscord database info: ' + userId);
console.error(err);
return;
}
ids = JSON.stringify(rows.map((el) => el.channel));
check();
});
const title = data.title || null;
const startTime = data.started_at || null;
const str = 'SELECT * FROM TwitchStreams WHERE user=? AND game=? AND ' +
'TIMESTAMPDIFF(MINUTE, startTime, NOW()) < 60 && title=?';
const toSend2 = global.sqlCon.format(str, [userId, gameId, title]);
global.sqlCon.query(toSend2, (err, rows) => {
if (err) {
this.error('Failed to check stream history database info: ' + userId);
console.error(err);
return;
}
if (rows && rows.length > 0) {
this.debug(
'Aborting twitch alert due to recent identical alert: ' + userId);
insert(true);
return;
}
insert();
});
}
/**
* @description Handle a user's stream ending.
* @private
* @param {string} userId The user's ID of which the stream has ended.
*/
_twitchStreamEnd(userId) {
const str =
'UPDATE TwitchStreams SET endTime=FROM_UNIXTIME(?) WHERE user=? ' +
'ORDER BY id DESC LIMIT 1';
const toSend = global.sqlCon.format(str, [Date.now(), userId]);
global.sqlCon.query(toSend, (err) => {
if (err) {
this.error('Failed to update stream end time database info: ' + userId);
console.error(err);
} else {
this.debug('Updated stream end time: ' + userId);
}
});
}
/**
* @description Fetch a Twitch game information from its ID.
* @public
* @param {string} gameId The ID of the game to fetch.
* @param {Function} cb First parameter is an optional error string, otherwise
* the second parameter is the fetched data object.
*/
fetchGameData(gameId, cb) {
const toSend = global.sqlCon.format(
'SELECT * FROM TwitchGames WHERE id=? AND ' +
'TIMESTAMPDIFF(DAY, lastModified, NOW()) <= 7',
[gameId]);
global.sqlCon.query(toSend, (err, rows) => {
if (err) {
this.warn('Failed to fetch TwitchGames database info: ' + gameId);
console.error(err);
cb('DB_FAILED');
return;
}
if (rows && rows.length > 0) {
cb(null, rows[0]);
} else {
const host = WebApi._fetchGameHost;
host.path += encodeURIComponent(gameId);
const req = https.request(host, (res) => {
let content = '';
res.on('data', (chunk) => content += chunk);
res.on('end', () => {
if (res.statusCode == 200) {
this._parseTwitchGameResponse(content, cb);
} else {
this.error(res.statusCode + ': ' + content);
console.error(host);
cb(res.statusCode + ' from Twitch');
}
});
});
req.end();
}
});
}
/**
* @description Parse the response from Twitch containing information about a
* game.
* @private
* @param {string} content Received content from request to Twitch API.
* @param {Function} cb First parameter is optional error string, otherwise
* the second parameter is the game object.
*/
_parseTwitchGameResponse(content, cb) {
try {
const parsed = JSON.parse(content);
const data = parsed.data && parsed.data[0];
if (!data) {
cb(null, null);
return;
}
const toSend = global.sqlCon.format(
'INSERT INTO TwitchGames SET id=?, name=?, thumbnailUrl=? ' +
'ON DUPLICATE KEY UPDATE name=?, thumbnailUrl=?',
[data.id, data.name, data.box_art_url, data.name, data.box_art_url]);
global.sqlCon.query(toSend, (err) => {
if (err) {
this.error(
'Failed to update TwitchGames data after fetching game: ' +
data.name + ' ' + data.id);
console.log(err);
}
cb(null, {
id: data.id,
name: data.name,
thumbnailUrl: data.box_art_url,
lastModified: new Date().toUTCString(),
});
});
} catch (err) {
this.error('Failed to parse response from Twitch');
console.error(err, content);
cb('Bad Response');
return;
}
}
/**
* @description All data has been received for a Twitch webhook request, and
* is ready to be sent to our shards for re-broadcast and alerting.
* @private
* @param {string} ids Encoded array of ids to broadcast to shards.
* @param {string} content Encoded content string to broadcast to shards.
*/
_twitchFinal(ids, content) {
const toEval = `this.twitchWebhookHandler(${ids}, ${content})`;
if (this.common.isMaster) {
process.send({_sEval: toEval}, (err) => {
if (!err) return;
this.common.error('Failed to send broadcast to parent process!');
console.error(err);
});
} else if (this.client.shard) {
this.client.shard.broadcastEval(toEval).catch((err) => {
this.error('Failed to broadcast webhook handler to shards.');
console.error(err);
});
} else {
const sm = this.bot.getSubmodule('./twitch.js');
if (!sm) return;
sm.webhookHandler(ids, content);
}
}
}
module.exports = new WebApi();