plugapi
Version:
Generic API for building plug.dj bots
1,213 lines (1,071 loc) • 136 kB
JavaScript
'use strict';
// Node.JS Core Modules
const http = require('http');
const Https = require('https');
const util = require('util');
// Third-party Modules
const autoBind = require('auto-bind');
const constants = require('./constants.json');
const EventEmitter3 = require('eventemitter3');
const handleErrors = require('handle-errors')('PlugAPI', true);
const pRetry = require('p-retry');
const WebSocket = require('ws');
const chalk = require('chalk');
const got = require('got');
const plugMessageSplit = require('plug-message-split');
const tough = require('tough-cookie');
const Socket = require('./socket.js');
/**
* node.js HTTPS agent with keepAlive enabled
* @const
* @type {exports}
* @private
*/
const agent = new Https.Agent({
keepAlive: true
});
// plugAPI
/**
* BufferObject class
* @type {BufferObject|exports}
* @private
*/
const BufferObject = require('./bufferObject');
/**
* Event Object Types
* @type {exports}
* @private
*/
const EventObjectTypes = require('./eventObjectTypes');
/**
* Room class
* @type {Room|exports}
* @private
*/
const Room = require('./room');
/**
* Package.json of plugAPI
* @const
* @type {exports}
* @private
*/
const PlugAPIInfo = require('../package.json');
/**
* Storage for private data that should never be publiclly accessable
* @type {exports|WeakMap}
* @private
*/
const store = require('./privateVars');
/**
* REST Endpoints
* @const
* @type {{CHAT_DELETE: String, HISTORY: String, MODERATE_ADD_DJ: String, MODERATE_BAN: String, MODERATE_BOOTH: String, MODERATE_MOVE_DJ: String, MODERATE_MUTE: String, MODERATE_PERMISSIONS: String, MODERATE_REMOVE_DJ: String, MODERATE_SKIP: String, MODERATE_UNBAN: String, MODERATE_UNMUTE: String, PLAYLIST: String, ROOM_CYCLE_BOOTH: String, ROOM_INFO: String, ROOM_LOCK_BOOTH: String, USER_SET_AVATAR: String, USER_GET_AVATARS: String}}
* @private
*/
const endpoints = require('./endpoints.json');
/**
* DateUtilities written by plug.dj & Modified by TAT (TAT@plugcubed.net)
* @copyright 2014 - 2017 by Plug DJ, Inc.
* @private
*/
const DateUtilities = {
MONTHS: ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'],
SERVER_TIME: null,
OFFSET: 0,
setServerTime(e) {
this.SERVER_TIME = this.convertUnixDateStringToDate(e);
this.OFFSET = this.SERVER_TIME.getTime() - Date.now();
},
yearsSince(e) {
return this.serverDate().getFullYear() - e.getFullYear();
},
monthsSince(e) {
const t = this.serverDate();
return ((t.getFullYear() - e.getFullYear()) * 12) + (t.getMonth() - e.getMonth());
},
daysSince(e) {
const t = this.serverDate();
const n = t.getTime();
const r = e.getTime();
const i = 864e5;
let s = (n - r) / i;
const o = (n - r) % i / i;
if (o > 0 && o * i > this.secondsSinceMidnight(t) * 1e3) {
s++;
}
return ~~s;
},
hoursSince(e) {
return ~~((this.serverDate().getTime() - e.getTime()) / 36e5);
},
minutesSince(e) {
return ~~((this.serverDate().getTime() - e.getTime()) / 6e4);
},
secondsSince(e) {
return ~~((this.serverDate().getTime() - e.getTime()) / 1e3);
},
monthName(e, t) {
const n = this.MONTHS[e.getMonth()];
return t ? n : n.substr(0, 3);
},
secondsSinceMidnight(e) {
const t = new Date(e.getTime());
this.midnight(t);
return ~~((e.getTime() - t.getTime()) / 1e3);
},
midnight(e) {
e.setHours(0);
e.setMinutes(0);
e.setSeconds(0);
e.setMilliseconds(0);
},
minutesUntil(e) {
return ~~((e.getTime() - this.serverDate().getTime()) / 6e4);
},
secondsUntil(e) {
return ~~((e.getTime() - this.serverDate().getTime()) / 1e3);
},
millisecondsUntil(e) {
return e.getTime() - this.serverDate().getTime();
},
serverDate() {
return new Date(Date.now() + this.OFFSET);
},
getServerEpoch() {
return Date.now() + this.OFFSET;
},
getSecondsElapsed(e) {
return !e || Object.is(e, '0') ? 0 : this.secondsSince(new Date(e.substr(0, e.indexOf('.'))));
},
convertUnixDateStringToDate(e) {
return e ? new Date(e.substr(0, 4), Number(e.substr(5, 2)) - 1, e.substr(8, 2), e.substr(11, 2), e.substr(14, 2), e.substr(17, 2)) : null;
}
};
/**
* Create instance of PlugAPI.
* @param {String} authenticationData.email The email to login with.
* @param {String} authenticationData.password The password to login with.
* @param {Boolean} [authenticationData.guest = null] True to login as guest.
* @param {String} authenticationData.facebook.accessToken The access token for Facebook login.
* @param {String} authenticationData.facebook.userID The user ID for facebook login.
* @param {Function} [callback] An optional callback utilized in async mode
* @extends {EventEmitter3}
*
* @example
* <caption>There are multiple ways to create a bot. Sync vs Async, FB vs guest or email. Please choose only one of the examples to use.</caption>
* // Sync
* // Password
* const bot = new PlugAPI({email: 'something@something.com', password: 'hunter2'});
*
* // Facebook
* // To login with fb will require logging in via plug and viewing the data sent in to /_/auth/facebook via the network tab of dev tools.
*
* const bot = new PlugAPI({
* facebook: {
* userID: 'xxxxxxxx',
* accessToken: 'xxxxxx'
* }
* });
*
* // Guest
* const bot = new PlugAPI();
* // OR
* const bot = new PlugAPI({ guest: true });
*
* // Async
*
* // Password
* new PlugAPI({email: 'something@something.com', password: 'hunter2'}, (err, bot) => {
* if (err) throw new Error(err);
*
* });
*
* // Facebook
* new PlugAPI({
* facebook: {
* userID: 'xxxxxxxx',
* accessToken: 'xxxxxx'
* }
* }, (err, bot) => {
* if (err) throw new Error(err);
* });
*
* // Guest
* new PlugAPI({ guest: true }, (err, data) => {
* if (err) throw new Error(err);
* ]});
*
* @constructor
*/
class PlugAPI extends EventEmitter3 {
constructor(authenticationData, callback) {
super();
autoBind(this);
/**
* Jethro Logger
* @type {Logger|exports}
* @private
*/
this.logger = require('./logger');
/**
* Is User a guest?
* @type {Boolean}
* @private
*/
this.guest = false;
/**
* Is User logging in via Facebook?
* @type {Boolean}
* @private
*/
this.fbLogin = false;
/**
* Slug of room, that the bot is currently connecting to
* @type {null|String}
* @private
*/
store(this)._connectingRoomSlug = null;
/**
* Authentication information (e-mail and password)
* THIS MUST NEVER BE ACCESSIBLE NOR PRINTING OUT TO CONSOLE
* @type {Object}
* @private
*/
store(this)._auththenticationInfo = null;
/**
* Queue of outgoing chat messages
* @type {Object}
* @private
*/
store(this)._chatQueue = {
queue: [],
sent: 0,
limit: 10,
running: false
};
/**
* plug.dj url to send https requests
* @private
* @type {String}
*/
this.baseUrl = 'https://plug.dj';
/**
* Socket url to send socket events to.
* @private
* @type {String}
*/
this.socketUrl = 'wss://ws-prod.plug.dj:443/socket';
/**
* WebSocket (connection to plug.DJ's socket server)
* @type {null|WebSocket}
* @private
*/
store(this)._ws = null;
/**
* Instance of Room
* @type {Room}
* @private
*/
store(this)._room = new Room();
/**
* Is everything initialized?
* @type {Boolean}
* @private
*/
store(this)._initialized = false;
/**
* Prefix that defines if a message is a command. Default is !
* @type {String}
*/
this.commandPrefix = '!';
/**
* List over chat history
* Contains up to 512 messages
* @type {Array}
* @private
*/
store(this)._chatHistory = [];
/**
* Contains informations about requests sent to server
* @type {{queue: Array, sent: Number, limit: Number, running: Boolean}}
* @private
*/
store(this)._serverRequests = {
queue: [],
sent: 0,
limit: 10,
running: false
};
/**
* Checks if a number is finite and not an empty string.
* @param {*} number The variable to check if it's a number or not.
* @returns {Boolean} True if a number. False if not.
* @private
*/
store(this)._isNumber = (number) => isFinite(Number(number)) && !Object.is(number, '');
/**
* Queue that the bot should connect to the socket server
* @param {String} roomSlug Slug of room to join after connection
* @private
*/
store(this)._queueConnectSocket = (roomSlug) => {
store(this)._serverRequests.queue.push({
type: 'connect',
server: 'socket',
room: roomSlug
});
if (!store(this)._serverRequests.running) {
store(this)._queueTicker();
}
};
/**
* Connect to plug.DJ's socket server
* @param {String} roomSlug Slug of room to join after connection
* @private
*/
store(this)._connectSocket = (roomSlug) => {
store(this)._ws = new Socket(this.socketUrl);
store(this)._ws.on('open', () => {
store(this)._getAuthCode((authCode) => {
store(this)._sendEvent('auth', authCode);
this.logger.success('plug.dj Socket Server', chalk`{green Authenticated as a ${this.guest ? 'guest' : 'user'}}`);
this.emit('connected');
this.emit('server:socket:connected');
});
});
store(this)._ws.on('message', (data) => {
if (!Object.is(data, 'h')) {
const payload = JSON.parse(data || '[]');
for (let i = 0; i < payload.length; i++) {
store(this)._ws.emit('data', payload[i]);
}
}
});
store(this)._ws.on('data', store(this)._messageHandler.bind(this));
store(this)._ws.on('data', (data) => this.emit('tcpMessage', data));
store(this)._ws.on('error', (a) => {
this.logger.error('plug.dj Socket Server', a);
});
store(this)._ws.on('reconnecting', () => {
this.logger.info('Socket Server', 'Reconnecting');
});
store(this)._ws.on('close', (a) => {
this.logger.warn('plug.dj Socket Server', `Closed with Code ${a}`);
});
store(this)._ws.connect();
};
/**
* The ticker that runs through the server queue and executes them when it's time
* @private
*/
store(this)._queueTicker = () => {
store(this)._serverRequests.running = true;
const canSend = store(this)._serverRequests.sent < store(this)._serverRequests.limit;
const obj = store(this)._serverRequests.queue.pop();
if (canSend && obj) {
store(this)._serverRequests.sent++;
if (Object.is(obj.type, 'rest')) {
store(this)._sendREST(obj.opts, obj.callback);
} else if (Object.is(obj.type, 'connect')) {
if (Object.is(obj.server, 'socket')) {
store(this)._connectSocket(obj.room);
}
}
setTimeout(() => {
store(this)._serverRequests.sent--;
}, 6e4);
}
if (store(this)._serverRequests.queue.length > 0) {
setImmediate(store(this)._queueTicker);
} else {
store(this)._serverRequests.running = false;
}
};
/**
* The ticker that runs through the chat queue and executes them when it's time
* @private
*/
store(this)._queueChatTicker = () => {
const delay = (time) => {
return new Promise((resolve) => setTimeout(resolve, time));
};
store(this)._chatQueue.running = true;
if (store(this)._chatQueue.queue.length > 0) {
if (store(this)._floodProtectionDelay < store(this)._floodProtectionDelayMax) {
store(this)._floodProtectionDelay += 75;
}
const obj = store(this)._chatQueue.queue.shift();
if (obj) {
store(this)._sendEvent(PlugAPI.events.CHAT, obj.msg);
if (!Object.is(obj.timeout, undefined) && store(this)._isNumber(obj.timeout) && Number(obj.timeout) >= 0) {
const specificChatDeleter = (data) => {
if (Object.is(data.raw.uid, store(this)._room.getSelf().id) && Object.is(data.message.trim(), obj.msg.trim())) {
setTimeout(() => {
this.moderateDeleteChat(data.raw.cid);
}, Number(obj.timeout) * 1E3);
this.off(PlugAPI.events.CHAT, specificChatDeleter);
}
};
this.on(PlugAPI.events.CHAT, specificChatDeleter);
}
}
delay(store(this)._floodProtectionDelay).then(() => store(this)._queueChatTicker());
} else {
store(this)._chatQueue.running = false;
store(this)._floodProtectionDelay = 0;
}
};
/**
* Function to increment or decrement the playlist length count.
* @type {Function}
* @param {Array} playlist the playlist to modify
* @param {Boolean} increment Whether to increment or decrement the playlist count.
* @returns {Array} The playlist is returned with the modified count
* @private
*/
store(this)._editPlaylistLength = (playlist, increment) => {
if (Object.is(typeof increment, 'undefined') || Object.is(increment, true)) {
playlist.count++;
} else {
playlist.count--;
}
return playlist;
};
/**
* Convert seconds to human readable format of HH:MM:SS
* @param {Number} seconds The amount of time in seconds
* @private
* @returns {String} A string in the format of HH:MM:SS
*/
store(this)._convertSecondsToHMS = (seconds) => {
if (!store(this)._isNumber(seconds)) return '00:00';
let secondsRemaining = parseInt(seconds, 10);
const hoursRemaining = Math.floor(secondsRemaining / 3600);
secondsRemaining %= 3600;
const minsRemaining = Math.floor(secondsRemaining / 60);
secondsRemaining %= 60;
return `${hoursRemaining > 10 ? hoursRemaining : `0${hoursRemaining}`}:${minsRemaining > 10 ? minsRemaining : `0${minsRemaining}`}:${secondsRemaining > 10 ? secondsRemaining : `0${secondsRemaining}`}`;
};
/**
* Check if an object contains a value
* @param {Object} obj The object
* @param {*} value The value
* @param {Boolean} [strict] Whether to use strict mode check or not
* @private
* @returns {Boolean} if object contains value
*/
store(this)._objectContainsValue = (obj, value, strict) => {
for (const i in obj) {
if (!obj.hasOwnProperty(i)) continue;
if ((!strict && obj[i] == value) || (strict && Object.is(obj[i], value))) return true; // eslint-disable-line eqeqeq
}
return false;
};
/**
* Common callback for all API calls
*
* @callback RESTCallback
* @name RESTCallback
*
* @param {null|String} err Error message on error; otherwise null
* @param {null|*} data Data on success; otherwise null
*/
/**
* Queue REST request
* @param {String} method REST method
* @param {String} endpoint Endpoint on server
* @param {Object|Undefined} [data] Data
* @param {RESTCallback|Undefined} [restCallback] Callback function
* @param {Boolean} [skipQueue] Skip queue and send the request immediately
* @private
*/
store(this)._queueREST = (method, endpoint, data, restCallback, skipQueue) => {
restCallback = Object.is(typeof restCallback, 'function') ? restCallback : () => { };
const opts = {
method,
headers: Object.assign(store(this)._headers, {
cookie: this.jar.getCookieStringSync('https://plug.dj'),
Referer: `https://plug.dj/${store(this)._room.getRoomMeta().slug == null ? '' : store(this)._room.getRoomMeta().slug}`
}),
url: `${this.baseUrl}/_/${endpoint}`
};
if (data != null) {
opts.body = data;
}
if (skipQueue && Object.is(skipQueue, true)) {
store(this)._sendREST(opts, restCallback);
} else {
store(this)._serverRequests.queue.push({
type: 'rest',
opts,
callback: restCallback
});
if (!store(this)._serverRequests.running) {
store(this)._queueTicker();
}
}
};
/**
* Send a REST request
* @param {Object} opts An object of options to send in to the request module.
* @param {RESTCallback} sendCallback Callback function
* @private
*/
store(this)._sendREST = (opts, sendCallback) => {
const request = (attempts) => {
if (Object.is(attempts % 5, 0)) this.logger.debug('REST', `Route: ${opts.url} sendREST attempt ${attempts}/10`);
return got(opts.url, Object.assign(opts, {
headers: store(this)._headers,
agent,
json: true
})).then((body) => {
body = body.body;
if (body && Object.is(body.status, 'ok')) {
return sendCallback(null, body.data);
}
return sendCallback(body && body.status ? body.status : new Error("Can't connect to plug.dj"), null);
}).catch((err) => {
if (err && err.statusCode) {
if (Object.is(err.url, 'https://plug.dj/_/rooms/join') && err.statusCode < 500) {
process.nextTick(() => {
const statusMessage = `${err.statusCode} - ${http.STATUS_CODES[err.statusCode]}`;
let errMessage = '';
if (err.response && err.response.body) {
const status = err.response.body.status;
const data = err.response.body.data;
if (Object.is(status, 'ban') && data && data[0]) {
if (Object.is(data[0].e, -1)) {
errMessage = `${statusMessage} Can't join room due to being permanently banned`;
} else {
errMessage = `${statusMessage} Can't join room due to being banned. Time remaining: ${store(this)._convertSecondsToHMS(data[0].e)}`;
}
} else if (Object.is(status, 'roomCapacity')) {
errMessage = `${statusMessage} Can't join room due to room being over capacity.`;
} else if (Object.is(status, 'requestError') && data) {
errMessage = `${statusMessage} Can't join room due to a request error. Data: ${JSON.stringify(err.response.body, null, 2)}`;
} else if (Object.is(status, 'notFound')) {
errMessage = `${statusMessage} Can't join room. Invalid Room slug.`;
} else {
errMessage = `${statusMessage} Can't join room. Data: ${JSON.stringify(err.response.body, null, 2)}`;
}
if (!Object.is(errMessage, '')) {
throw new Error(errMessage);
}
}
throw new Error(`${statusMessage} Can't join room.`);
});
} else if (Object.is(err.statusCode, 403)) {
if (Object.is(err.url, 'https://plug.dj/_/booth') || Object.is(err.url, 'https://plug.dj/_/booth/add')) {
return sendCallback(err, null);
}
process.nextTick(() => {
throw new Error(`${opts.url} responded with ${err.statusCode} (${err.statusMessage}`);
});
}
throw err;
}
process.nextTick(() => {
throw new Error(err);
});
});
};
pRetry(request, {
retries: 10,
randomize: true,
maxTimeout: 10000
})
.catch((err) => {
this.logger.error('REST', `Route: ${opts.url} ${(err ? err : '')} Guest Mode: ${this.guest}`);
return sendCallback(`Route: ${opts.url} ${(err ? err : '')} Guest Mode: ${this.guest}`, null);
});
};
/**
* Get the room state.
* @param {RESTCallback} roomstateCallback Callback function
* @private
*/
store(this)._getRoomState = (roomstateCallback) => {
store(this)._queueREST('GET', 'rooms/state', undefined, (err, data) => {
if (err) {
throw new Error(`Error getting room state: ${err ? err : 'Unknown error'}`);
}
store(this)._connectingRoomSlug = null;
store(this)._initRoom(data[0], () => {
if (Object.is(typeof roomstateCallback, 'function')) {
return roomstateCallback(null, data);
}
});
});
};
/**
* Queue that the bot should join a room.
* @param {String} slug Slug of room to join after connection
* @param {RESTCallback} [joinCallback] Callback function
* @private
*/
store(this)._joinRoom = (slug, joinCallback) => {
store(this)._queueREST('POST', 'rooms/join', {
slug
}, (err) => {
if (err) return;
store(this)._getRoomState(joinCallback);
});
};
/**
* Perform the login process using credentials.
* @param {Function} loginCallback Callback
* @private
*/
store(this)._performLoginCredentials = (loginCallback) => {
/**
* Is the login process running.
* Used for sync
* @type {boolean}
* @private
*/
let loggingIn = true;
const setCookie = (res) => {
if (Array.isArray(res.headers['set-cookie'])) {
res.headers['set-cookie'].forEach((item) => {
this.jar.setCookieSync(item, 'https://plug.dj');
});
} else if (Object.is(typeof res.headers['set-cookie'], 'string')) {
this.jar.setCookieSync(res.headers['set-cookie'], 'https://plug.dj');
}
};
const handleLogin = (body) => {
if (body && body.body && !Object.is(body.body.status, 'ok')) {
handleErrors.error(`Login Error: ${body && body.body && !Object.is(body.body.status, 'ok') ? `API Status: ${body.body.status}` : ''} ${body && !Object.is(body.statusCode, 200) ? `HTTP Status: ${body.body.statusCode} - ${http.STATUS_CODES[body.body.statusCode]}` : ''}`, loginCallback);
} else {
loggingIn = false;
if (Object.is(typeof loginCallback, 'function')) {
return loginCallback(null, this);
}
}
};
const handleError = (err) => {
process.nextTick(() => {
handleErrors.error(`Login Error: \n${err || ''}`, loginCallback);
});
};
if (this.guest) {
const guestLogin = (guestAttempts) => {
if (Object.is(guestAttempts % 5, 0)) this.logger.debug('PlugAPI', `Attempt #${guestAttempts} to connect to plug.dj as guest`);
return got(`${this.baseUrl}/plugcubed`).then((data) => {
if (data.body) {
const serverTime = data.body.split('_st')[1].split('"')[1];
DateUtilities.setServerTime(serverTime);
loggingIn = false;
setCookie(data);
if (Object.is(typeof loginCallback, 'function')) {
return loginCallback(null, this);
}
} else if (Object.is(typeof loginCallback, 'function')) {
return loginCallback(new Error('unable to connect to plug.dj'), null);
}
});
};
pRetry(guestLogin, {
forever: true,
randomize: true,
maxTimeout: 10000
})
.catch(handleError);
} else {
const login = (attempts) => {
if (Object.is(attempts % 5, 0)) this.logger.debug('PlugAPI', `Attempt #${attempts} to obtain server data from plug.dj`);
return got(`${this.baseUrl}/_/mobile/init`, {
headers: store(this)._headers,
json: true
}).then((indexBody) => {
const info = indexBody.body;
const csrfToken = info.data[0].c;
const serverTime = info.data[0].t;
DateUtilities.setServerTime(serverTime);
setCookie(indexBody);
if (this.fbLogin) {
const fbLogin = (facebookAttempts) => {
if (Object.is(facebookAttempts % 5, 0)) this.logger.debug('PlugAPI', `Attempt #${facebookAttempts} to login to plug.dj`);
return got(`${this.baseUrl}/_/auth/facebook`, {
json: true,
method: 'POST',
headers: Object.assign(store(this)._headers, {
cookie: this.jar.getCookieStringSync('https://plug.dj')
}),
body: {
csrf: csrfToken,
accessToken: store(this)._auththenticationInfo.facebook.accessToken,
userID: store(this)._auththenticationInfo.facebook.userID
}
})
.then(handleLogin)
.catch((fbError) => {
process.nextTick(() => {
handleErrors.error(fbError.stack || fbError, callback);
});
});
};
pRetry(fbLogin, {
forever: true,
randomize: true,
maxTimeout: 10000
})
.catch(handleError);
} else {
const authLogin = (loginAttempts) => {
if (Object.is(loginAttempts % 5, 0)) this.logger.debug('PlugAPI', `Attempt #${loginAttempts} to login to plug.dj`);
return got(`${this.baseUrl}/_/auth/login`, {
json: true,
method: 'POST',
headers: Object.assign(store(this)._headers, {
cookie: this.jar.getCookieStringSync('https://plug.dj')
}),
body: {
csrf: csrfToken,
email: store(this)._auththenticationInfo.email,
password: store(this)._auththenticationInfo.password
}
})
.then(handleLogin)
.catch((err2) => {
process.nextTick(() => {
if (err2 && err2.statusCode) {
if (err2.statusCode >= 400 && err2.statusCode < 500) {
if (Object.is(err2.statusCode, 400)) {
handleErrors.error(`Login Error: Missing email or password | HTTP Status: ${err2.message}`, loginCallback);
} else if (Object.is(err2.statusCode, 401)) {
handleErrors.error(`Login Error: Incorrect email or password | HTTP Status: ${err2.message}`, loginCallback);
} else if (Object.is(err2.statusCode, 403)) {
handleErrors.error(`Login Error: Incorrect CSRF Token. | HTTP Status: ${err2.message}`, loginCallback);
}
} else {
handleErrors.error(err2.stack || err2, loginCallback);
}
}
});
});
};
pRetry(authLogin, {
forever: true,
randomize: true,
maxTimeout: 10000
})
.catch(handleError);
}
});
};
pRetry(login, {
forever: true,
randomize: true,
maxTimeout: 10000
})
.catch(handleError);
}
if (!Object.is(typeof loginCallback, 'function')) {
const deasync = require('deasync');
// Wait until the session is set
while (loggingIn) { // eslint-disable-line no-unmodified-loop-condition
deasync.sleep(100);
}
}
};
/**
* Get the auth code for the user
* @param {Function} authCallback Callback function
* @private
*/
store(this)._getAuthCode = (authCallback) => {
const getAuthCode = () => {
store(this)._headers.cookie = this.jar.getCookieStringSync('https://plug.dj');
return got(`${this.baseUrl}/_/auth/token`, {
headers: store(this)._headers,
json: true
}).then((body) => {
return authCallback(body.body.data[0]);
});
};
pRetry(getAuthCode, {
randomize: true,
forever: true,
maxTimeout: 10000
})
.catch(console.error);
};
/**
* Handling incoming messages.
* Emitting the correct events depending on commands, mentions, etc.
* @param {Object} messageData plug.DJ message event data
* @private
*/
store(this)._receivedChatMessage = (messageData) => {
if (!store(this)._initialized || messageData.from == null) return;
let i;
const mutedUser = store(this)._room.isMuted(messageData.from.id);
const prefixChatEventType = (mutedUser && !this.mutedTriggerNormalEvents ? 'muted:' : '');
if (messageData.message.startsWith(this.commandPrefix) && (this.processOwnMessages || !Object.is(messageData.from.id, store(this)._room.getSelf().id))) {
const cmd = messageData.message.substr(this.commandPrefix.length).split(' ')[0];
messageData.command = cmd;
messageData.args = messageData.message.substr(this.commandPrefix.length + cmd.length + 1);
const random = Math.ceil(Math.random() * 1E10);
// Arguments
if (Object.is(messageData.args, '')) {
messageData.args = [];
} else {
// Mentions => Mention placeholder
let lastIndex = -1;
let allUsers = store(this)._room.getUsers();
if (allUsers.length > 0) {
allUsers = allUsers.sort((a, b) => {
if (Object.is(a.username.length, b.username.length)) {
return 0;
}
return a.username.length < b.username.length ? -1 : 1;
});
for (const user of allUsers) {
lastIndex = messageData.args.toLowerCase().indexOf(user.username.toLowerCase());
if (lastIndex > -1) {
messageData.args = `${messageData.args.substr(0, lastIndex).replace('@', '')}%MENTION-${random}-${messageData.mentions.length}% ${messageData.args.substr(lastIndex + user.username.length + 1)}`;
messageData.mentions.push(user);
}
}
}
messageData.args = messageData.args.split(' ').filter((item) => item != null && !Object.is(item, ''));
for (i = 0; i < messageData.args.length; i++) {
if (store(this)._isNumber(messageData.args[i])) {
messageData.args[i] = Number(messageData.args[i]);
}
}
}
// Mention placeholder => User object
if (messageData.mentions.length > 0) {
for (i = 0; i < messageData.mentions.length; i++) {
const atIndex = messageData.args.indexOf(`@%MENTION-${random}-${i}%`);
const normalIndex = messageData.args.indexOf(`%MENTION-${random}-${i}%`);
if (normalIndex > -1) {
messageData.args[normalIndex] = messageData.mentions[i];
}
if (atIndex > -1) {
messageData.args[atIndex] = messageData.mentions[i];
}
}
}
// Pre command handler
if (Object.is(typeof this.preCommandHandler, 'function') && Object.is(this.preCommandHandler(messageData), false)) return;
// Functions
messageData.respond = function() {
const message = Array.prototype.slice.call(arguments).join(' ');
return this.sendChat(`@${messageData.from.username} ${message}`);
}.bind(this);
messageData.respondTimeout = function() {
const args = Array.prototype.slice.call(arguments);
const timeout = parseInt(args.splice(args.length - 1, 1), 10);
const message = args.join(' ');
return this.sendChat(`@${messageData.from.username} ${message}`, timeout);
}.bind(this);
messageData.havePermission = (permission, permissionCallback) => {
if (permission == null) {
permission = PlugAPI.ROOM_ROLE.NONE;
}
if (this.havePermission(messageData.from.id, permission)) {
if (Object.is(typeof permissionCallback, 'function')) {
return permissionCallback(true);
}
return true;
}
if (Object.is(typeof permissionCallback, 'function')) {
return permissionCallback(false);
}
return false;
};
messageData.isFrom = (ids, success, failure) => {
if (Object.is(typeof ids, 'string') || Object.is(typeof ids, 'number')) {
ids = [ids];
}
if (ids == null) {
if (Object.is(typeof failure, 'function')) {
return failure();
}
return false;
}
const isFrom = ids.indexOf(messageData.from.id) > -1;
if (isFrom && Object.is(typeof success, 'function')) {
return success();
} else if (!isFrom && Object.is(typeof failure, 'function')) {
return failure();
}
return isFrom;
};
if (!mutedUser) {
this.emit(prefixChatEventType + PlugAPI.events.CHAT_COMMAND, messageData);
this.emit(`${prefixChatEventType}${PlugAPI.events.CHAT_COMMAND}:${cmd}`, messageData);
if (this.deleteCommands) {
if (!this.deleteMessageBlocks) {
if (!(Object.is(store(this)._lastMessageUID, messageData.raw.uid) || Object.is(store(this)._lastMessageType, messageData.type))) {
this.moderateDeleteChat(messageData.raw.cid);
}
} else {
this.moderateDeleteChat(messageData.raw.cid);
}
}
}
} else if (Object.is(messageData.type, 'emote')) {
this.emit(`${prefixChatEventType}${PlugAPI.events.CHAT}:emote`, messageData);
}
store(this)._lastMessageUID = messageData.raw.uid;
store(this)._lastMessageType = messageData.type;
this.emit(prefixChatEventType + PlugAPI.events.CHAT, messageData);
this.emit(`${prefixChatEventType}${PlugAPI.events.CHAT}:${messageData.raw.type}`, messageData);
if (!Object.is(store(this)._room.getSelf(), null) && messageData.message.indexOf(`@${store(this)._room.getSelf().username}`) > -1) {
this.emit(`${prefixChatEventType}${PlugAPI.events.CHAT}:mention`, messageData);
}
};
/**
* Sends in a websocket event to plug's servers
* @param {String} type The Event type to send in
* @param {String} data The data to send in.
* @private
*/
store(this)._sendEvent = (type, data) => {
if (store(this)._ws != null && Object.is(store(this)._ws.connection.readyState, WebSocket.OPEN)) {
store(this)._ws.send(JSON.stringify({
a: type,
p: data,
t: DateUtilities.getServerEpoch()
}));
}
};
/**
* Initialize the room with the room state data
* @param {Object} data the room state Data
* @param {Function} roomCallback Callback function
* @private
*/
store(this)._initRoom = (data, roomCallback) => {
store(this)._room.reset();
store(this)._room.setRoomData(data);
store(this)._queueREST('GET', endpoints.HISTORY, undefined, store(this)._room.setHistory.bind(store(this)._room));
this.emit(PlugAPI.events.ADVANCE, {
currentDJ: store(this)._room.getDJ(),
djs: store(this)._room.getDJs(),
lastPlay: {
dj: null,
media: null,
score: null
},
media: store(this)._room.getMedia(),
startTime: store(this)._room.getStartTime(),
historyID: store(this)._room.getHistoryID()
});
this.emit(PlugAPI.events.ROOM_JOIN, data.meta.name);
store(this)._initialized = true;
roomCallback();
};
/*
* The handling of incoming messages from the Plug.DJ socket server.
* If any cases are returned instead of breaking (stopping the emitting to the user code) it MUST be commented just before returning.
* @param {Object} msg Socket events sent in from plug.dj
* @private
*/
store(this)._messageHandler = (msg) => {
/**
* Event type
* @private
* @type {PlugAPI.events}
*/
const type = msg.a;
/**
* Data for the event
* Will lookup in EventObjectTypes for possible converter function
* @private
* @type {*}
*/
let data = EventObjectTypes[msg.a] != null ? EventObjectTypes[msg.a](msg.p, store(this)._room) : msg.p;
let i;
const reconnect = (isAck) => {
if (store(this)._room.getRoomMeta().slug) {
this.logger.warn('PlugAPI', `${isAck ? 'Reconnecting' : 'Reconnecting because of KILL_SESSION (logging in more than once on plug.dj)'}`);
this.close(true);
if (isAck) {
store(this)._performLoginCredentials();
}
this.connect(store(this)._room.getRoomMeta().slug);
}
};
switch (type) {
case 'ack':
if (!Object.is(data, '1')) {
reconnect(true);
return;
}
const slug = store(this)._connectingRoomSlug ? store(this)._connectingRoomSlug : store(this)._room.getRoomMeta().slug;
store(this)._queueREST('GET', endpoints.USER_INFO, null, (err, userData) => {
if (err) throw new Error(`Error Obtaining user info. ${err}`);
store(this)._room.setSelf(userData[0]);
store(this)._joinRoom(slug, () => {
this.logger.success('plug.dj Socket Server', chalk`{green Connected as a ${this.guest ? 'guest' : 'user'}}`);
});
});
// This event should not be emitted to the user code.
return;
case PlugAPI.events.ADVANCE:
// Add information about lastPlay to the data
data.lastPlay = {
dj: store(this)._room.getDJ(),
media: store(this)._room.getMedia(),
score: store(this)._room.getRoomScore()
};
store(this)._room.advance(data);
store(this)._queueREST('GET', endpoints.HISTORY, undefined, store(this)._room.setHistory.bind(store(this)._room));
// Override parts of the event data with actual User objects
data.currentDJ = store(this)._room.getDJ();
data.djs = store(this)._room.getDJs();
break;
case PlugAPI.events.CHAT:
// If over limit, remove the first item
if (store(this)._chatHistory.push(data) > 512) store(this)._chatHistory.shift();
store(this)._receivedChatMessage(data);
// receivedChatMessage will emit the event with correct chat object and correct event types
return;
case PlugAPI.events.CHAT_DELETE:
for (i = 0; i < store(this)._chatHistory.length; i++) {
if (store(this)._chatHistory[i] && Object.is(store(this)._chatHistory[i].id, data.c)) {
store(this)._chatHistory.splice(i, 1);
}
}
break;
case PlugAPI.events.ROOM_LONG_DESCRIPTION_UPDATE:
store(this)._room.setRoomLongDescription(data.d);
break;
case PlugAPI.events.ROOM_SHORT_DESCRIPTION_UPDATE:
store(this)._room.setRoomDescription(data.d);
break;
case PlugAPI.events.ROOM_DESCRIPTION_UPDATE:
store(this)._room.setRoomDescription(data.description);
break;
case PlugAPI.events.ROOM_NAME_UPDATE:
store(this)._room.setRoomName(data.name);
break;
case PlugAPI.events.ROOM_WELCOME_UPDATE:
store(this)._room.setRoomWelcome(data.welcome);
break;
case PlugAPI.events.USER_JOIN:
store(this)._room.addUser(data);
break;
case PlugAPI.events.USER_LEAVE:
let userData = store(this)._room.getUser(data);
if (userData == null || Object.is(data, 0)) {
userData = {
id: data,
guest: Object.is(data, 0)
};
}
store(this)._room.removeUser(data);
this.emit(type, userData);
// This is getting emitted with the full user object instead of only the user ID
return;
case PlugAPI.events.USER_UPDATE:
store(this)._room.updateUser(data);
this.emit(type, this.getUser(data.i));
// This is getting emitted with the full user object instead of only the user ID
return;
case