tokebot/lib/bot.js
2021-12-06 19:59:30 -05:00

2061 lines
65 KiB
JavaScript

"use strict";
const path = require("path");
const fs = require("fs");
const sio = require("socket.io-client");
const C = require("cli-color");
const fetch = require("node-fetch");
const ent = require("html-entities").AllHtmlEntities;
const EventHandlers = require("./eventhandlers.js");
const clicmd = require("./clicommands.js");
const utils = require("./utils.js");
const chatcmd = require("./chatcommands.js");
const strings = require("./strings.js");
const classes = require("./classes.js");
const PLATFORM = ((process && process.platform) ? process.platform : "win32");
/**
* class for Bot object.
* @constructor
* @param {Object} config Configuration object. Read from config.js
* @param {Object} readline Readline interface object
* @param {String} ROOT Root filepath (absolute) of the bot.
*/
function Bot(config, readline, ROOT) {
this.rl = readline;
this.readlineInitialized = false;
this.ROOTPATH = ROOT;
this.DiscordBot = null;
if (config.discord.use) {
if (config.discord.token.trim() !== "") {
this.DiscordBot = require("./discordbot.js").init(this, config.discord.token);
} else {
this.logger.error(strings.format(this, "DISCORD_ERR_INIT", ["no token given"]));
}
}
const ROOM = config.login.room;
this.settingsFile = config.advanced.useChannelSettingsFile ? "settings-" + ROOM + ".json" : "settings.json";
var streamOpts = {
flags: 'a',
encoding: 'utf8',
fd: null,
mode: 0o660,
autoClose: true
};
var logStream = fs.createWriteStream(path.join(ROOT, "logs", ROOM, "bot.log"), streamOpts),
errStream = fs.createWriteStream(path.join(ROOT, "logs", ROOM, "err.log"), streamOpts),
modStream = fs.createWriteStream(path.join(ROOT, "logs", ROOM, "mod.log"), streamOpts),
mediaStream = fs.createWriteStream(path.join(ROOT, "logs", ROOM, "media.log"), streamOpts),
serverStream = fs.createWriteStream(path.join(ROOT, "logs", ROOM, "server.log"), streamOpts),
debugStream = config.interface.logDebug ? fs.createWriteStream(path.join(ROOT, "logs", ROOM, "debug.log"), streamOpts) : null;
this.getLogStream = function() {return logStream;}
let commonLog = (label, message, stream, consolidate)=>{
if (!message) return;
this.write(C.blackBright(utils.getTimestamp(config.interface.useTwentyFourHourTime)) + " " + label + message, stream, consolidate);
}
this.logger = {
log: (message) => { if (!message) return;
commonLog("", message, logStream, false);
},
verbose: (message) => { if (!message) return;
if (config.interface.logVerbose)
commonLog(C.magentaBright("* "), C.blueBright(message), logStream, false);
},
debug: (message) => { if (!message) return;
if (config.interface.logDebug)
commonLog(C.magenta("[DEBUG] "), C.magentaBright(message), debugStream, config.interface.logConsolidation);
},
info: (message) => { if (!message) return;
commonLog(C.blue("[INFO] "), C.blueBright(message), logStream, false);
},
media: (message) => { if (!message) return;
commonLog(C.yellowBright("[MEDIA] "), message, mediaStream, config.interface.logConsolidation);
},
error: (message) => { if (!message) return;
commonLog(C.red("[ERROR] "), C.redBright(message), errStream, (config.interface.logConsolidation && !this.cfg.interface.excludeErrorsFromLog));
},
warn: (message) => { if (!message) return;
commonLog(C.yellow("[WARN] "), C.yellowBright(message), logStream, false);
},
mod: (message) => { if (!message) return;
commonLog(C.green("[MOD] "), C.greenBright(message), modStream, config.interface.logConsolidation);
},
cylog: (message) => { if (!message) return;
commonLog(C.cyan("[SERVER] "), C.cyanBright(message), serverStream, config.interface.logConsolidation);
}
}
this.killed = false;
this.botName = "ChozoBot";
this.version = "0.9961a";
this.RANKS = config.RANKS;
this.RANKS["SITEADMIN"] = 255;
this.actionQueue = new classes.AutoFnQueue(config.misc.queueInterval);
this.afk = false;
this.broadcastPMQueue = new classes.AutoFnQueue(config.misc.broadcastPMQueueInterval);
this.bumpStats = {
lastBumpedUIDs: [],
users:{},
bumpingUIDs:[]
};
this.changingPartition = false;
this.CHANNEL = {
badEmotes: [],
banlist: [],
currentMedia: null,
currentUID: -1,
emoteMap: {},
emotes: [],
leader: "",
opts: {},
perms: {},
playlist: [],
playlistIsLocked: false,
playlistMeta: { count: 0, rawTime: 0, time: 0 },
poll: {
active: false,
title: '',
options: [],
counts: [],
initiator: '',
timestamp: -1
},
rankList: [],
room: ROOM,
usercount: 0, //includes anons, updated with usercount event
users: [],
voteskip: { count: 0, need: 0 }
};
this.cfg = {
api: config.api,
connection: config.connection,
db: config.db,
discord: config.discord,
interface: config.interface,
chat: config.chat,
media: config.media,
misc: config.misc,
moderation: config.moderation,
advanced: config.advanced,
rankNames: config.rankNames
};
//set defaults on important cfg option(s) in case users have not updated their config:
if (!this.cfg.connection.hasOwnProperty("sameDomainSocketOnly")) {
this.cfg.connection["sameDomainSocketOnly"] = true;
}
this.currentVideoData = {
id: null,
comments: null,
commentsDisabled: false,
views: -1,
likes: 0,
dislikes: 0,
ratingsDisabled: false,
noStats: false
}
this.db = require("./db.js");
this.duels = [];
//This is for runonce things such as requesting the rank list.
this.first = {
grabbedChannelOpts: true,
grabbedChannelRanks: true,
grabbedPermissions: true,
motdChange: true,
playlistLock: true
};
this.largeDataReqQueue = new classes.AutoFnQueue(config.misc.largeDataQueueInterval);
this.leadFinishingMedia = false;
this.gettingBanList = false;
this.gettingComments = false;
this.gettingVideoMeta = false;
this.guest = false;
this.handlingChatCommands = false;
this.hasConnectedBefore = false;
this.KICKED = false;
this.lastLowPlaylistNotification = Date.now();
this.leader = false;
this.leadTimer = null;
this.logged_in = false;
//Used with minuteTick for half-hours and such to avoid multiple timers
this.minutesPassed = 0;
this.notifiedLowPlaylistTime = false;
this.pendingLanguageChange = null;
this.rank = 0;
this.seek = {
time: -1,
autoUnassign: true
};
this.socketConnErrors = 0;
this.started = Date.now(); //let's timestamp when the bot started because that's useful
this.userCooldowns = {};
this.username = "";
this.timeouts = {
changeMediaLead: null,
minuteTick: null,
playingNext: null,
},
this.trigger = this.validateTrigger(config.chat.trigger);
this.settings = {
muted: false,
disallow: [],
timeBans: {},
minRankOverrides: {},
rankMatchOverrides: {},
userCooldownOverrides: {},
cmdCooldownOverrides: {},
cmdStateOverrides: {},
userData: {},
mediaBlacklist:[],
userBlacklist:[],
lucky:{},
flatSkiprate: {
managing: false,
target: -1,
original_rate: -1
},
};
this.logger.info(strings.format(this, "INIT", [this.botName, this.version]));
this.logger.verbose(strings.format(this, "ACTIONQUEUE_INIT_INTERVAL", [config.misc.queueInterval]));
//read settings.json and if there was an error (file doesn't exist) then write a new one
//also, initializes chat commands
this.readSettings(hasErr => {
if (!hasErr) {
if (this.getOpt("muted", false)) {
this.logger.warn(strings.format(this, "BOT_MUTED_INIT"));
}
if (!this.getOpt("timeBans", {}).hasOwnProperty(ROOM)) {
this.settings.timeBans[ROOM] = [];
}
chatcmd.init(this);
}
});
/**
* Takes socket server config and attempts to establish a connection. Sets event handlers.
* @function
* @name connectToServer
* @param {Object} server Socket server config object
*/
let connectToServer = (server)=>{
this.logger.debug(strings.format(this, "DBG_FOUND_SOCKET", [server.url]));
if (!this.cfg.connection.sameDomainSocketOnly) {
this.logger.warn("sameDomainSocketOnly is disabled!");
}
var fn = (()=>{
if (this.cfg.connection.sameDomainSocketOnly && utils.getHostname(server.url).toLowerCase() !== config.connection.hostname.toLowerCase()) {
this.logger.error(strings.format(this, "SERVER_BAD_HOSTNAME", [config.connection.hostname]));
this.kill("bad server hostname");
} else {
this.socket = sio(server.url, {secure: server.secure});
EventHandlers.setHandlers(this, this.socket, config);
}
});
this.actionQueue.enqueue([this, fn, []]);
}
this.getSocketConfig(this.CHANNEL.room, config.connection.secureSocket, connectToServer);
//for use with partitionChange
//NOT TESTED FULLY! could work though
this.hardReconnect = function(socketConfig) {
if (this.socket && this.socket.connected && !this.changingPartition) {
this.socket.disconnect();
}
this.hasConnectedBefore = false;
this.logger.log(strings.format(this, "CONNECTING"));
let i = 0,
server = null,
servers = socketConfig.servers;
for (;i < servers.length; i++) {
if (servers[i].secure === config.connection.secureSocket) {
server = servers[i];
break;
}
}
if (!server && servers.length >= 1) {
server = servers[0];
} else if (!server) {
this.kill("No socket config handed over during partition change", 2000, 0);
return;
}
this.changingPartition = false;
connectToServer(server);
}
}
/**
* Tries to set leader to the given user.
*
* @param {string} name A username. Can be blank ("") to remove the current leader.
* @return {boolean} True if the given user is in the room; false if not, or no leaderctl permissions.
*/
Bot.prototype.assignLeader = function(name) {
if (!this.checkChannelPermission("leaderctl")) {
this.logger.error("Tried to assign a leader to " + name + ", but bot doesn't have leaderctl perms");
if (!this.leader && name.toLowerCase() === this.username.toLowerCase()) {
this.seek.time = -1;
}
return false;
}
let usr = (name === "" ? {name: ""} : this.getUser(name));
if (usr) {
let fn = ()=>{this.socket.emit("assignLeader", {name:usr.name})};
this.actionQueue.enqueue([this, fn, []]);
return true;
}
return false;
}
/**
* Removes a given user from the disallowed list.
*
* @param {string} username Username.
* @return {boolean} True if the user was previously disallowed. False otherwise.
*/
Bot.prototype.allowUser = function(username) {
if (!username || username.trim() === "") return;
username = username.toLowerCase().trim();
var da = this.getOpt("disallow", []);
let i = 0;
for (; i < da.length; i++) {
if (da[i] === username) {
this.logger.info(strings.format(this, "USER_ALLOWED", [username]));
utils.unsortedRemove(da, i);
this.writeSettings();
return true;
}
}
this.logger.info(strings.format(this, "USER_ALLOWED_FAIL", [username]));
return false;
}
/**
* Sends a private message to multiple users at once.
*
* @param {string[]} users Users to send messages to.
* @param {string} message The message.
*/
Bot.prototype.broadcastPM = function(users, message) {
if (this.settings.muted) return;
var fn = ((username, message)=>{
this.socket.emit("pm", {
"to":C.strip(username),
"msg":C.strip(message)
});
});
let i = 0;
for(;i < users.length; i++) {
this.broadcastPMQueue.enqueue([this, fn, [users[i], message]])
}
}
/**
* Sends a private message to all online mods.
*
* @param {string} message Message to send.
*/
Bot.prototype.broadcastModPM = function(message) {
this.broadcastPM(this.getOnlineMods(true, true), message);
}
/**
* Check if the bot has a given channel permission.
*
* @param {string} perm Permission to check against. See bottom of bot.js for a list
* @return {boolean} True if bot has permission, otherwise false.
*/
Bot.prototype.checkChannelPermission = function(perm) {
return this.userHasChannelPermission(perm, this.rank);
}
/**
* Check if bot has multiple channel permissions.
*
* @param {string[]} perms Array of permissions to check.
* @return {boolean} True if bot has every permission, otherwise false.
*/
Bot.prototype.checkChannelPermissions = function(perms) {
let i = 0;
for (;i < perms.length;i++) {
if (!this.userHasChannelPermission(perms[i], this.rank)) return false;
}
return true;
}
/**
* Unbans all timebanned users if their times have expired.
*
* @return {boolean} False if bot cannot ban or banlist is empty, otherwise true.
*/
Bot.prototype.checkTimeBans = function() {
let bl = this.CHANNEL.banlist,
tb = this.settings.timeBans[this.CHANNEL.room];
if (bl.length <= 0 || !this.checkChannelPermission("ban")) return false;
let i = 0;
for (;i < tb.length; i++) {
if (Date.now() >= tb[i].unbanTime) {
this.unbanUser(tb[i].name);
}
}
return true;
}
/**
* Purges blacklisted users, and deletes blacklisted media from the playlist.
*
* @return {boolean} False if bot cannot delete media, otherwise true.
*/
Bot.prototype.cleanBlacklistedMedia = function() {
if (!this.checkChannelPermission("playlistdelete")) return false;
let pl = this.CHANNEL.playlist,
bu = this.getOpt("userBlacklist", []),
bm = this.getOpt("mediaBlacklist", []),
i = 0,
purge = [],
del = [];
for (;i < pl.length;i++){
let usr = pl[i].queueby.toLowerCase();
let userIsBlacklisted = ~bu.indexOf(usr);
if (!~purge.indexOf(usr) && userIsBlacklisted) {
purge.push(pl[i].queueby);
} else if (!userIsBlacklisted && this.mediaIsBlacklisted(pl[i])) {
del.push(pl[i].uid);
}
}
for (i = 0;i < purge.length; i++) {
this.purgeUser(purge[i]);
}
for (i = 0;i < del.length; i++) {
this.deleteVideo(del[i]);
}
return true;
}
/**
* Cleans bot state. Dangerous: Only use if disconnected!
*/
Bot.prototype.cleanState = function() {
let room = this.CHANNEL.room;
this.CHANNEL = {
badEmotes: [],
banlist: [],
currentMedia: null,
currentUID: -1,
emoteMap: {},
emotes: [],
leader: "",
opts: {},
perms: {},
playlist: [],
playlistIsLocked: false,
playlistMeta: { count: 0, rawTime: 0, time: 0 },
poll: {
active: false,
title: '',
options: [],
counts: [],
initiator: '',
timestamp: -1
},
rankList: [],
room: room,
usercount: 0,
users: [],
voteskip: { count: 0, need: 0 }
};
this.afk = false;
this.first = {
grabbedChannelOpts: true,
grabbedChannelRanks: true,
grabbedPermissions: true,
loadedBlacklist: true,
motdChange: true,
playlistLock: true
};
this.leadFinishingMedia = false;
this.gettingBanList = false;
this.guest = false;
this.leader = false;
this.leadTimer = null;
this.logged_in = false;
this.rank = 0;
this.socketConnErrors = 0;
this.username = "";
}
/**
* Carries out a duel (the given object).
*
* @param {Object} duel Object containing duel info.
* @return {boolean} False if duel is null, otherwise true.
*/
Bot.prototype.commenceDuel = function(duel) {
if (!duel) return false;
let A = 0,
B = 0;
//Setting the min/max to the same number will cause an infinite loop
function roll() {
A = Math.floor((Math.random() * 100) + 1);
B = Math.floor((Math.random() * 100) + 1);
}
while (A === B) {
roll();
}
let strArgs = [duel[0], duel[1], A, B, "/tinyrekt"];
if (A > B) {
this.sendChatMsg(strings.format(this, "DUEL_RESULT_WIN", strArgs));
this.db.run("insertDuelRecord", [duel[0], duel[1]]);
} else {
this.sendChatMsg(strings.format(this, "DUEL_RESULT_LOSS", strArgs));
this.db.run("insertDuelRecord", [duel[1], duel[0]]);
}
return true;
}
/**
* Attempts to delete the video with the given UID.
*
* @param {number} uid UID of the desired video
* @return {boolean} True if UID is a number and bot can delete media, otherwise false.
*/
Bot.prototype.deleteVideo = function(uid) {
if (typeof uid === "number" && this.checkChannelPermission("playlistdelete")) {
let fn = ()=>{this.socket.emit("delete", uid)};
this.actionQueue.enqueue([this, fn, []]);
return true;
}
return false;
}
/**
* Takes a media object and deletes all occurrences of media with the same type and ID.
*
* @param {Object} media Media object.
* @return {boolean} False if bot cannot delete media or see the playlist, otherwise true.
*/
Bot.prototype.deleteVideoAndDupes = function(media) {
if (!this.checkChannelPermissions(["playlistdelete", "seeplaylist"])) return false;
let _media = media.hasOwnProperty("media") ? media.media : media;
if (_media && _media.id && _media.type) {
let vids = this.getMediaAll(_media.id, _media.type),
i = vids.length-1;
for (;i>=0;i--) {
this.deleteVideo(vids[i].uid);
}
}
return true;
}
/**
* Check if a user is disallowed.
*
* @param {string} username Username.
* @return {boolean} True if given user is disallowed, otherwise false.
*/
Bot.prototype.disallowed = function(username) {
return ~this.settings.disallow.indexOf(username.toLowerCase().trim());
}
/**
* Disallows a user.
*
* @param {string} username Username.
* @return {boolean} True if user is not already disallowed, false otherwise.
*/
Bot.prototype.disallowUser = function(username) {
if (!username || username.trim() === "") return false;
username = username.toLowerCase().trim();
if (!~this.getOpt("disallow", []).indexOf(username)) {
this.settings.disallow.push(username);
this.writeSettings();
this.logger.info(strings.format(this, "USER_DISALLOWED", [username]));
return true;
} else
this.logger.info(strings.format(this, "USER_DISALLOWED_FAIL", [username]));
return false;
}
/**
* Check if an emote exists in the channel's emote list.
*
* @param {string} emote Name of emote as it would be used normally in chat.
* @return {boolean} True if emote exists, false if not.
*/
Bot.prototype.emoteExists = function(emote) {
return (this.CHANNEL.emoteMap.hasOwnProperty(emote) || ~this.CHANNEL.badEmotes.indexOf(emote));
}
/**
* Get number of AFK users in the room.
*
* @return {number} Number of AFK users.
*/
Bot.prototype.getAFKCount = function() {
var users = this.CHANNEL.users;
var afk = 0;
let i = 0;
for (; i < users.length; i++) {
if (users[i].meta.afk)
afk++;
}
return afk;
}
/**
* Send socket request to retrieve the ban list. Can be quite heavy, use with care.
*/
Bot.prototype.getBanList = function() {
if (this.checkChannelPermission("ban")) {
if (!this.gettingBanList) {
this.gettingBanList = true;
let fn = ()=>{this.socket.emit("requestBanlist")};
this.largeDataReqQueue.enqueue([this, fn, []]);
}
} else {
this.CHANNEL.banlist = [];
}
}
//WARNING! Only works if the bot is rank >= 3, so be careful
/**
* Get rank of user from the actual rank list. Must be rank >= 3 to retrieve the list.
*
* @param {string} name Username.
* @return {number} Rank of the user if they are moderator+. -1 if user is not a mod, or the bot cannot see the rank list.
*/
Bot.prototype.getChanRank = function(name) {
name = name.toLowerCase();
let i = 0,
rl = this.CHANNEL.rankList;
for (;i < rl.length; i++) {
if (rl[i].name === name) {
return rl[i].rank;
}
}
return -1;
}
/**
* Gets the currently active poll as an object that can be used to create another poll. Does not include initiator or timestamp.
*
* @return {Object|null} Poll object, null if no poll active or blank poll
*/
Bot.prototype.getCurrentPollFrame = function() {
let poll = this.CHANNEL.poll;
if (!poll || !poll.active || (poll.options.length <= 0 && poll.title.trim() === "")) return null;
let obscured = (poll.counts.length >= 1 && (typeof poll.counts[0] === "string") && poll.counts[0].indexOf("?") >= 0);
return {
title: poll.title,
opts: poll.options,
obscured: obscured
}
}
/**
* Gets the full media object from a media's UID
*
* @param {number} uid Media UID
* @return {Object|null} Media object matching the given UID, or null if not found.
*/
Bot.prototype.getMedia = function(uid) {
var index = this.getMediaIndex(uid);
if (~index) return this.CHANNEL.playlist[index];
return null;
}
/**
* Get a list of all media objects matching an ID and Type. Good for duplicates if allowed.
*
* @param {string} id Media ID, such as Video ID of YouTube videos.
* @param {string} type Host type abbreviation (yt, vi, dm, etc.)
* @return {Object[]} An array containing any matching media objects.
*/
Bot.prototype.getMediaAll = function(id, type) {
if (!id || !type) return [];
let vids = [],
i = 0,
pl = this.CHANNEL.playlist;
for (;i < pl.length; i++) {
if (pl[i].media && pl[i].media.id === id && pl[i].media.type === type) {
vids.push(pl[i]);
}
}
return vids;
}
/**
* Gets the first index of a media object within the playlist matching the provided UID.
*
* @param {number} uid Media UID.
* @return {number} Index of media object within the playlist. -1 if not found.
*/
Bot.prototype.getMediaIndex = function(uid) {
var i = 0;
for (; i < this.CHANNEL.playlist.length; i++) {
if (this.CHANNEL.playlist[i]["uid"] === uid) {
return i;
}
}
return -1;
}
/**
* Gets a list of all mods (users with rank >= 2) online.
*
* @param {boolean} excludeSelf If true, excludes bot from the resulting array.
* @return {string[]} Array of usernames.
*/
Bot.prototype.getOnlineMods = function(excludeSelf, excludeBots) {
let mods = [],
users = this.CHANNEL.users,
i = 0;
for (;i<users.length;i++) {
if (!(excludeSelf && users[i].name === this.username) && !(excludeBots && ~this.cfg.misc.bots.indexOf(users[i].name)) && users[i].rank >= this.RANKS.MOD) {
mods.push(users[i].name);
}
}
return mods;
}
/**
* Gets an option from settings.json, setting a default value if not found.
*
* @param {string} opt Option name.
* @param {*} def Default value if the option was not found.
* @return {*} Value of the option, or the default value given.
*/
Bot.prototype.getOpt = function(opt, def) {
if (this.settings.hasOwnProperty(opt)) return this.settings[opt];
this.logger.warn("getOpt: could not find " + opt + ", setting default value");
this.settings[opt] = def;
this.writeSettings();
return def;
}
/**
* Gets saved user data from settings.json.
*
* @param {string} username A username.
* @return {Object} User data object. Empty if not found.
*/
Bot.prototype.getSavedUserData = function(username) {
username = username.toLowerCase();
if (this.settings.userData.hasOwnProperty(username))
return this.settings.userData[username];
return {};
}
/**
* Queries CyTube for room socket info.
*
* @param {string} room Room name.
* @param {boolean} secure If true, select a secure server.
* @param {connectToServer} callback Callback to send the socket server object to.
*/
Bot.prototype.getSocketConfig = function(room, secure, callback) {
if (typeof callback !== "function") {
return this.logger.error(strings.format(this, "CALLBACK_INVALID", ["getSocketConfig"]));
}
if (!secure) {
this.logger.warn(strings.format(this, "SERVER_INSECURE"))
}
var fn = (()=>{
fetch("https://"+this.cfg.connection.hostname+"/socketconfig/" + room + ".json")
.then((res)=>{
if (!res.ok) {
this.logger.error(strings.format(this, "SERVER_ROOM_NOT_FOUND", [room]));
this.logger.warn("The server may be having issues, or the room was not found.");
this.logger.info("Retrying connection in 60 seconds. Use CTRL+C to exit.");
setTimeout(()=>{
this.getSocketConfig(room, secure, callback);
}, 60000);
throw new Error(res.statusText);
} else {
return res.json();
}
})
.then((json)=>{
var servers = json.servers;
let i = 0;
for (; i < servers.length; i++) {
if (servers[i]["secure"] === secure) {
return servers[i];
}
}
return "";
})
.then((server)=>{
if (server && server.url && server.url.trim() !== "") {
callback(server);
return true;
} else {
this.logger.error(strings.format(this, "SERVER_ROOM_NOT_FOUND", [room]));
return false;
}
})
.catch((error)=>{
this.logger.error(strings.format(this, "SERVER_REQUEST_ERROR", [error.stack]));
this.logger.warn("The server may be down. Check to see if you can connect to it using a browser. Use CTRL+C to exit.");
});
});
this.actionQueue.enqueue([this, fn, []]);
}
/**
* Get user object from a username.
*
* @param {string} username Username.
* @return {Object|null} User object if the given user is in the room, or null if not.
*/
Bot.prototype.getUser = function(username) {
var i = 0;
username = username.toLowerCase();
for (; i < this.CHANNEL.users.length; i++) {
if (this.CHANNEL.users[i].name.toLowerCase() === username) {
return this.CHANNEL.users[i];
}
}
return null;
}
/**
* Get duel object that the given username is in.
*
* @param {string} username A username.
* @param {boolean=} remove Optional, but if true, removes the object from the duel pool.
* @return {Object|null} Duel object if found, null otherwise.
*/
Bot.prototype.getUserDuel = function(username, remove) {
let i = 0;
for (;i < this.duels.length; i++) {
if (~this.duels[i].indexOf(username)) {
let duel = this.duels[i];
if (remove) {
clearTimeout(duel[2]);
utils.unsortedRemove(this.duels, i);
}
return duel;
}
}
return null;
}
/**
* Gets the index of a user object in the userlist with the given username.
*
* @param {string} username A username.
* @return {number} Index in the userlist of the user object if found, otherwise -1.
*/
Bot.prototype.getUserIndex = function(username) {
var i = 0;
username = username.toLowerCase();
for (; i < this.CHANNEL.users.length; i++) {
if (this.CHANNEL.users[i].name.toLowerCase() === username) {
return i;
}
}
return -1;
}
/**
* Gets the amount of users in the room that are both not AFK and able to voteskip.
*
* @return {number} Count of users who can skip
*/
Bot.prototype.getUsersWithSkipPerms = function() {
let min = this.CHANNEL.perms.voteskip,
users = this.CHANNEL.users,
i = 0,
count = 0;
for (; i < users.length; i++) {
if (!users[i].meta.afk && users[i].rank >= min) {
count++;
}
}
return count;
}
/**
* Gets a list of videos added by the specified user.
*
* @param {string} username A username.
* @return {Object[]} Array of media objects added by the specified user.
*/
Bot.prototype.getUserVideos = function(username) {
username = username.toLowerCase();
let vids = [],
pl = this.CHANNEL.playlist,
i = 0;
for (;i < pl.length;i++) {
if (pl[i].queueby.toLowerCase() === username) {
vids.push(pl[i]);
}
}
return vids;
}
/**
* Resets some things depending on the bot's rank and permissions.
*/
Bot.prototype.handlePermissionChange = function() {
if (!this.checkChannelPermission("ban")) {
this.CHANNEL.banlist = [];
}
if (!this.checkChannelPermission("seeplaylist")) {
this.CHANNEL.playlist = [];
}
if (!this.checkChannelPermission("voteskip")) {
this.CHANNEL.voteskip.count = 0;
this.CHANNEL.voteskip.need = 0;
}
if (this.rank < 2) {
//get our options first just to be able to log that we're disabling it
let skipopts = this.getOpt("flatSkiprate", {
managing: false,
target: -1,
original_rate: -1
});
if (skipopts.managing) {
this.logger.warn("Disabling automatic flat skiprate. Rank is too low.");
}
this.setOpt("flatSkiprate", {
managing: false,
target: -1,
original_rate: -1
})
}
this.setProgTitle();
}
/**
* Stops the bot and prepares the process to end.
*
* @param {string} reason Reason for killing the bot. Mostly for logging purposes.
* @param {number} timeout Amount of time in milliseconds to wait until setting the exit code. Minimum is 1000.
* @param {number=} exitCode Defaults to 0. Sets the process's exit code to something else if needed. An exitcode of 3 is considered safe but prevents restarting.
*/
Bot.prototype.kill = async function(reason, timeout, exitCode) {
if (this.killed) return;
this.killed = true;
this.stopAllTimers();
this.handlingChatCommands = false;
this.setProgTitle();
if (this.DiscordBot) {
this.DiscordBot.client.destroy();
}
if (!timeout || timeout < 1000) timeout = 1000;
if (!reason) reason = "none given";
var sectext = timeout === 1000 ? " second" : " seconds";
this.logger.warn(strings.format(this, "EXIT", [(timeout/1000) + sectext, reason]));
await this.updateUserRoomTimeAll();
await this.db.endPool();
this.logger.debug("Reached end of await block in bot.kill");
if (this.socket && this.socket.connected)
this.socket.disconnect();
this.writeSettings();
this.rl.close();
if (exitCode !== null && typeof exitCode !== "number") exitCode = 1;
else if (typeof exitCode === "number") exitCode = Math.trunc(exitCode);
setTimeout(function() {
process.stdin.destroy();
process.exitCode = exitCode === null ? 0 : exitCode;
}, timeout);
}
/**
* Searches the banlist and finds usernames associated with subnets that match the given IP's subnet.
*
* @param {string} ip A user's IP
* @param {boolean=} mergeDupes If true, will shorten the output by including the amount of times each username occurs and excluding duplicate names from the output.
* @return {string[]} Returns array of usernames that were found. If mergeDupes is true, usernames that occurred twice or more will appear like: username (x) where x is the occurrence count
*/
Bot.prototype.matchSubnet = function(ip, mergeDupes) {
ip = ip.match(/.*\./);
let matches = [],
i = 0,
bl = this.CHANNEL.banlist,
counts = {};
for (;i < bl.length;i++){
if (~bl[i]["ip"].indexOf(ip)) {
if (mergeDupes) {
if (!counts.hasOwnProperty(bl[i].name)) {
matches.push(bl[i].name);
counts[bl[i].name] = 1;
}
else counts[bl[i].name]++;
} else {
matches.push(bl[i].name);
}
}
}
if (mergeDupes) {
i = 0;
for(;i < matches.length;i++) {
let count = counts[matches[i]];
if (count && count > 1) {
matches[i] += " (" + count + ")";
}
}
}
return matches;
}
/**
* Checks if the given media object is blacklisted.
*
* @param {Object} media Media object.
* @return {boolean} True if media is blacklisted, false if not found or invalid object.
*/
Bot.prototype.mediaIsBlacklisted = function(media) {
let _media = media.hasOwnProperty("media") ? media.media : media;
if (_media && _media.id && _media.type) {
return ~this.settings.mediaBlacklist.indexOf(utils.formatLink(_media.id, _media.type, true));
}
return false;
}
/**
* DO NOT CALL MANUALLY. Called whenever a mediaUpdate frame is received. Determines, if the bot is leader, if the current video is finished and the next video should play.
*
* @param {Object} data mediaUpdate frame data, usually containing current media states such as current time and paused state
*/
Bot.prototype.mediaUpdateTick = function(data) {
if (this.leader) {
let media = this.CHANNEL.currentMedia;
if (media && media.seconds > 0) {
let duration = media.seconds;
let diff = duration - data.currentTime;
if (diff < 1 && !this.leadFinishingMedia) {
this.leadFinishingMedia = true;
clearTimeout(this.timeouts.playingNext);
let next = ()=>{if (this.leader) this.socket.emit("playNext");}
if (diff === 0) next(); //avoid setting a timeout with 0ms delay
else this.timeouts.playingNext = setTimeout(next.bind(this), 1000);
}
}
}
}
/**
* Minute-long timeout loop that continously runs. Calling this will reset the currently active timer. Checks timebans every minute.
*
*/
Bot.prototype.minuteTick = function() {
clearTimeout(this.timeouts.minuteTick);
if (this.KILLED) return;
this.minutesPassed++;
if (this.CHANNEL.banlist.length > 0) {
this.checkTimeBans();
}
if (this.minutesPassed >= 30) {
this.minutesPassed = 0;
let bumpCd = this.cfg.media.bumpCooldown,
bumpUsers = this.bumpStats.users;
i = 0;
//Clean up bump cooldowns
for (var i in bumpUsers) {
if (Date.now() - bumpUsers[i] >= bumpCd) {
delete bumpUsers[i];
}
}
this.updateUserRoomTimeAll();
}
this.timeouts.minuteTick = setTimeout(()=>{
this.minuteTick();
}, 1*60*1000);
}
/**
* Mutes a given user.
*
* @param {!string} username Username to mute
* @param {?boolean=} shadow If true, shadowmutes instead
* @return {boolean} True if all conditions to mute succeed, otherwise false
*/
Bot.prototype.muteUser = function(username, shadow) {
if (this.checkChannelPermission("mute") && username && username.toLowerCase() !== this.username.toLowerCase()) {
let user = this.getUser(username);
if (user && ((!shadow && !user.meta.muted) || (shadow && !user.meta.smuted)) && this.rank > user.rank) {
let cmd = "/mute ";
if (shadow) cmd = "/smute ";
this.sendChatMsg(cmd + user.name, false, true);
return true;
}
}
return false;
}
/**
* Tries to move one media item after another using UIDs.
* @see {@link Bot#putMediaAtTop}
* @see {@link Bot#putMediaAtBottom}
* @param {number} fromUID UID of the media item to move.
* @param {number|string} afterUID UID of the item to move the media after. Can also be "prepend" or "append" to move the media to the very top/bottom of the playlist, respectively.
* @return {boolean} Returns false if fromUID is not given or both UIDs are the same, otherwise true.
*/
Bot.prototype.moveMedia = function(fromUID, afterUID) {
if (undefined == fromUID || fromUID === afterUID) {
return false;
}
let fn = (()=>{
this.socket.emit("moveMedia", {
from: fromUID,
after: afterUID
});
});
this.actionQueue.enqueue([this, fn, []]);
return true;
}
/**
* Logs the currently playing video and time if in progress, and sends a Discord embed if enabled.
*
*/
Bot.prototype.notifyVideoState = function() {
let _media = this.CHANNEL.currentMedia;
if (_media) {
let meta = this.getMedia(this.CHANNEL.currentUID);
if (meta) {
if (meta.media.id === _media.id && meta.media.type === _media.type) {
let link = utils.formatLink(_media.id, _media.type, false);
let username = meta.queueby === "" ? "(anon)" : meta.queueby;
let _args = [
utils.colorUsername(this, username),
utils.colorMediaTitle(this, _media.type, _media.title),
C.blackBright("[" + link + "]"),
C.blackBright("[t: " + _media.duration + "]")
];
let string = "NOW_PLAYING";
if (_media.currentTime > 5) {
_args.push(C.blackBright("[ct: " + utils.secsToTime(Math.floor(_media.currentTime)) + "]"));
string = "CURRENTLY_PLAYING";
}
this.logger.media(strings.format(this, string, _args));
if (this.DiscordBot && this.cfg.discord.sendNowPlayingMessages && this.cfg.discord.nowPlayingChannelID.trim() !== "") {
var color = "#00AD53";
if (this.DiscordBot.lastNowPlayingWasGreen)
color = "#A4479A";
this.DiscordBot.lastNowPlayingWasGreen = !this.DiscordBot.lastNowPlayingWasGreen;
this.DiscordBot.getChannel(this.cfg.discord.nowPlayingChannelID).send(
this.DiscordBot.createEmbed()
.setColor(color)
.setAuthor(':: now playing ::', this.cfg.discord.iconUrl, 'https://'+this.cfg.connection.hostname+'/r/' + this.CHANNEL.room)
.setTitle(ent.decode(_media.title))
.setDescription(link)
.addField("Added by", username, true)
.addField("Media Duration", _media.duration, true)
.setTimestamp()
);
}
} else {
this.logger.error("UID mismatch!");
}
}
}
}
/**
* Opens a poll with the given poll data object.
*
* @param {Object} pollData An object with poll data.
*/
Bot.prototype.openPoll = function(pollData) {
if (this.checkChannelPermission("pollctl") && pollData.hasOwnProperty("title") && pollData.hasOwnProperty("opts")) {
let fn = ()=>{this.socket.emit("newPoll", pollData)};
this.actionQueue.enqueue([this, fn, []]);
}
}
/**
* Cleans the playlist of any videos added by the given user.
* @see {@link Bot#purgeUsers}
* @param {string} username Username to purge.
* @param {boolean=} touch If true, will check to see if the user has at least one video before trying to purge.
* @return {boolean} True if bot can delete videos and if touch conditions pass, otherwise false.
*/
Bot.prototype.purgeUser = function(username, touch) {
if (this.checkChannelPermission("playlistdelete") && username.trim() !== "") {
if (!touch || (touch && this.touchUserVideos(username))) {
this.sendChatMsg("/clean " + username, false, true);
this.logger.mod("Attempted to purge " + username + ".");
return true;
}
}
return false;
}
/**
* Cleans the playlist of any videos added by the given users.
* @see {@link Bot#purgeUser}
* @param {string[]} users Users to purge.
* @param {boolean=} touch If true, will check to see if the user has at least one video before trying to purge.
*/
Bot.prototype.purgeUsers = function(users, touch) {
if (this.checkChannelPermission("playlistdelete")) {
let i = 0;
for (;i < users.length; i++) {
this.purgeUser(users[i], touch);
}
}
}
/**
* Moves a given media item to the very bottom of the playlist
* @see {@link Bot#moveMedia}
* @param {number} uid UID of media to move
*/
Bot.prototype.putMediaAtBottom = function(uid) {
this.moveMedia(uid, "append");
}
/**
* Moves a given media item to the very top of the playlist
* @see {@link Bot#moveMedia}
* @param {number} uid UID of media to move
*/
Bot.prototype.putMediaAtTop = function(uid) {
this.moveMedia(uid, "prepend");
}
/**
* Adds a piece of media to the playlist
* @param {string} pos Position to add the video to. Can be either "next" or "end"
* @param {string} src Link to the media. If it's custom, put the link into content and put "customembed" here instead.
* @param {boolean=} isTemp Whether the video will be temporary in the queue or not. Will be false if nothing is given.
* @param {string=} content Content, usually the media link, for custom embeds. Not needed otherwise.
* @param {string=} title Title for custom embeds.
*/
Bot.prototype.queueMedia = function(pos, src, isTemp, content, title) {
pos = pos.toLowerCase();
isTemp = !!isTemp;
if (!this.checkChannelPermission("playlistadd"))
return this.logger.error("Could not add ["+src+"]: insufficient permission to add videos.");
if (pos == "next" && !this.checkChannelPermission("playlistnext"))
return this.logger.error("Could not add ["+src+"], insufficient permission to add it as the next video.");
if (src == "customembed") {
if (!title) title = false;
this.actionQueue.enqueue([this, (()=>{
this.socket.emit("queue", {
id: content,
title: title,
pos: pos,
type: "cu",
temp: isTemp
})
}), []]);
} else {
let data = utils.parseMediaLink(src);
if (!data) return this.logger.error("Could not parse [" + src + "]");
if (data.type === "fi") {
if (data.id.match(/^http:/)) {
return this.logger.error("Could not queue ["+src+"]. Raw files must begin with 'https'.");
}
if (data.id.match(/kissanime|kimcartoon|kisscartoon|mega\.nz/i)) {
return this.logger.error("Could not queue ["+src+"]. Unsupported source.");
}
}
if (!data.id || !data.type) {
return this.logger.error("Could not parse [" + src + "]");
}
this.actionQueue.enqueue([this, (()=>{
this.socket.emit("queue", {
id: data.id,
type: data.type,
pos: pos,
duration: undefined,
title: title || false,
temp: isTemp
})
}), []]);
}
}
/**
* Emits a request to receive the channel log. MUST be Rank >=3 or it will kick you
*/
Bot.prototype.readChanLog = function() {
if (this.rank >= 3) { //ALWAYS PERFORM THIS CHECK! this rank is also hardcoded on CyTube
var fn = (()=>{this.socket.emit("readChanLog");});
this.largeDataReqQueue.enqueue([this, fn, []]);
}
}
/**
* Reads settings.json, looks for missing properties, and stores it
*
* @param {Function} callback Callback function to feed the read data into
* @return {*} Returns data returned by the callback
*/
Bot.prototype.readSettings = function(callback) {
var data;
try {
data = fs.readFileSync(path.join(this.ROOTPATH, this.settingsFile), "utf8");
} catch (err) {
if (err.code === "ENOENT") {
this.writeSettings();
} else {
this.logger.error(strings.format(this, "FILE_READ_ERROR", [this.settingsFile, err.stack]));
this.kill("error in settings file", 5000, 1);
return callback(true);
}
}
var oldSettings = this.settings;
var newSettings = !data ? {} : JSON.parse(data);
for (var i in oldSettings) {
if (!newSettings.hasOwnProperty(i)) {
this.logger.debug(strings.format(this, "SETTINGS_PROPERTY_MISSING", [i]));
newSettings[i] = oldSettings[i];
}
}
this.settings = newSettings;
this.logger.verbose(strings.format(this, "SETTINGS_READ"));
return callback(false);
}
/**
* Emits a request to grab the channel ranks. Need to be rank >= 3.
*/
Bot.prototype.requestChannelRanks = function() {
if (this.rank >= 3) {
var fn = (()=>{this.socket.emit("requestChannelRanks");});
this.largeDataReqQueue.enqueue([this, fn, []]);
}
}
/**
* Clears the current cache of stuff retrieved from the YouTube API, such as comments.
*/
Bot.prototype.resetCurrentVidData = function() {
this.currentVideoData = {
id: null,
comments: null,
commentsDisabled: false,
views: -1,
likes: 0,
dislikes: 0,
ratingsDisabled: false,
noStats: false
};
this.gettingComments = false;
this.gettingVideoMeta = false;
}
/**
* Sends a chat message.
*
* @param {!string} message Message to send
* @param {?boolean=} bypassQueue If true, will not be put into the action queue and will be sent immediately instead
* @param {?boolean=} isCommand If false, will pad the message with a space if the first char is /. If handling commands such as /afk or /ban for example, this MUST be true
* @param {?boolean=} ignoreMute If true, will send message regardless of bot's mute state. Defaults to false.
*/
Bot.prototype.sendChatMsg = function(message, bypassQueue, isCommand, ignoreMute) {
if (!isCommand && message.indexOf("\/") === 0) message = " " + message;
let canChat = ()=>{
return this.checkChannelPermission("chat") && ((this.settings.muted && (message.indexOf("\/") === 0 || ignoreMute)) || !this.settings.muted);
}
if (!canChat()) return;
var fn = ((msg)=>{
if (!canChat()) return;
var data = {msg: C.strip(message), meta: {}}
if (!data.msg) {
this.logger.warn(strings.format(this, "CHAT_BLANK_MESSAGE"));
return;
}
if (this.cfg.chat.useFlair) data.meta["modflair"] = this.rank;
this.socket.emit("chatMsg", data);
});
if (bypassQueue) fn(message);
else this.actionQueue.enqueue([this, fn, [message]]);
}
/**
* Sends a PM to the specified user.
*
* @param {!string} username Username to send the message to. Must be online
* @param {!string} message Message to send to the user
*/
Bot.prototype.sendPM = function(username, message) {
if (this.settings.muted) return;
var fn = ((username, message)=>{
this.socket.emit("pm", {
"to":C.strip(username),
"msg":C.strip(message)
});
});
this.actionQueue.enqueue([this, fn, [username, message]]);
}
/**
* Advances the current video's time 5 seconds and emits the player info. Automatically used when the bot is leader
*
* @param {?number=} absoluteTime If specified (seconds), will skip to this time instead of adding 5 seconds
*/
Bot.prototype.sendVideoUpdate = function(absoluteTime) {
if (!this.leader || !this.CHANNEL.currentMedia || this.CHANNEL.currentUID === null) return;
let media = this.CHANNEL.currentMedia,
currTime = media.currentTime,
duration = media.seconds,
newTime = currTime;
if (media && media.seconds <= 0) return;
if (absoluteTime !== null && absoluteTime >= 0 && absoluteTime <= duration) {
newTime = absoluteTime;
} else if (!media.paused) {
newTime += 5;
if (duration > 0 && newTime > duration) newTime = duration;
}
this.socket.emit("mediaUpdate", {
id: media.id,
currentTime: newTime,
paused: media.paused,
type: media.type
});
}
Bot.prototype.setChannelOpts = function(opts) {
if (this.rank < 2) return false;
let changed = false;
let temp_opts = {...this.CHANNEL.opts};
for (var i in opts) {
if (!temp_opts.hasOwnProperty(i)) {
this.logger.error("setChannelOpts: Tried to change channel option " + i + " but it doesn't already exist!");
} else if (i !== "password" && typeof temp_opts[i] !== typeof opts[i]) {
this.logger.error("setChannelOpts: Type mismatch for: " + i);
} else {
changed = true;
temp_opts[i] = opts[i];
}
}
if (changed) {
if (!temp_opts.password) temp_opts.password = "";
let fn = ()=>{
this.socket.emit("setOptions", temp_opts);
}
this.actionQueue.enqueue([this, fn, []]);
}
return changed;
}
Bot.prototype.setFlatSkiprate = function() {
let skipopts = this.getOpt("flatSkiprate", {
managing: false,
target: -1,
original_rate: -1
}
),
roomopts = this.CHANNEL.opts;
if (skipopts.managing) {
if (this.rank < 2) return false;
if (skipopts.target <= 0 || skipopts.original_rate < 0) {
skipopts.managing = false;
this.logger.warn("Disabling automatic flat skiprate. Target amount is set to 0 or below, or original rate is below 0");
this.setOpt("flatSkiprate", skipopts);
return false;
} else {
let skippers = this.getUsersWithSkipPerms();
if (skippers <= 0) return false;
if (Math.ceil(skippers * roomopts.voteskip_ratio) !== skipopts.target) {
return this.setChannelOpts({
voteskip_ratio: (skipopts.target / skippers)
});
}
return true;
}
}
return false;
}
/**
* Adds or removes media to/from the media blacklist.
*
* @param {!Object} media Media object, containing at the very least "id" and "type" keys
* @param {boolean=} state If true, adds the media to the blacklist; otherwise, removes it.
* @return {boolean} True if the media is not already in the blacklist when adding, or it is already in the list when removing; otherwise false.
*/
Bot.prototype.setMediaBlacklistState = function(media, state) {
let _media = media.hasOwnProperty("media") ? media.media : media;
if (_media && _media.id && _media.type) {
let link = utils.formatLink(_media.id, _media.type, true);
if (link === "") return false;
let blacklisted = this.mediaIsBlacklisted(_media);
if (!blacklisted && state) {
this.settings.mediaBlacklist.push(link);
} else if (blacklisted && !state) {
utils.unsortedRemove(this.settings.mediaBlacklist, ~blacklisted);
} else {
return false;
}
this.writeSettings();
return true;
}
return false;
}
/**
* Sets an option in bot.settings with the given data. Does not check for specific array/object type differences
*
* @param {!string} opt description
* @param {!*} data description
* @return {boolean} False if datatype mismatch, otherwise
*/
Bot.prototype.setOpt = function(opt, data) {
if (this.settings.hasOwnProperty(opt)) {
var dataType = typeof this.settings[opt];
var newDataType = typeof data;
if (dataType !== newDataType) {
let err = "setOpt: Tried changing " + opt + " from " + dataType + " to " + newDataType + "!";
this.logger.error(err);
throw new TypeError(err);
return false;
}
}
this.settings[opt] = data;
this.writeSettings();
return true;
}
/**
* Updates the terminal's title if interface.fancyTitle is true.
*
* @param {?string=} override If specified, will set the whole title to this string instead
*/
Bot.prototype.setProgTitle = function(override) {
if (!this.cfg.interface.fancyTitle) return;
var title = "";
if (override)
title = override;
else if (this.KICKED)
title = "(!KICKED!) | ";
else if (this.killed)
title = "(!KILLED!) | ";
else {
if (this.username === "" || !this.logged_in)
title = "not logged in | ";
else
title = this.username + " | ";
title += this.CHANNEL.room + " | ";
if (this.cfg.rankNames.hasOwnProperty(this.rank))
title += this.cfg.rankNames[this.rank] + " | ";
else if (this.rank === 0)
title += this.cfg.rankNames["unregistered"] + " | ";
if (this.CHANNEL.usercount > 0)
title += this.CHANNEL.usercount + " total users (" + (this.CHANNEL.usercount-this.CHANNEL.users.length) + " anon, "+ (this.CHANNEL.users.length-this.getAFKCount()) +" active) | ";
if (this.checkChannelPermission("seeplaylist", true))
title += this.CHANNEL.playlistMeta.count + " videos [" + this.CHANNEL.playlistMeta.time + "] "+ (this.CHANNEL.playlistIsLocked ? "L" : "UL") +" | ";
if (this.checkChannelPermission("viewvoteskip", true))
title += this.CHANNEL.voteskip.count + "/" + this.CHANNEL.voteskip.need + " skips | ";
if (this.CHANNEL.poll.active)
title += "Poll Active | ";
}
title += this.botName + " " + this.version;
if (PLATFORM === "win32")
process.title = title;
else
process.stdout.write("\x1B]0;" + title + "\x07");
}
/**
* Initializes the terminal's readline interface if not already done.
*/
Bot.prototype.setupReadline = function() {
if (this.readlineInitialized) return;
var rl = this.rl;
if (this.cfg.interface.allowQuickCLIChat) {
rl.setPrompt(C.green("chat") + C.greenBright(" > "), 2);
} else {
rl.setPrompt(C.red("cmd") + C.redBright(" $ "), 2);
}
rl.prompt();
rl.on('line', input => {
if (!this.readlineInitialized) {
this.logger.error(strings.format(this, "CLI_NOT_ACCEPTING_INPUT"));
} else if (input) {
if (input.trim() !== "") {
this.getLogStream().write(utils.getTimestamp(this.cfg.interface.useTwentyFourHourTime) + " " + strings.format(this, "CLI_INPUT", [input]) + "\r\n");
}
if (input.substr(0,1) === "/") {
clicmd.exec(this, input);
} else if (this.cfg.interface.allowQuickCLIChat) {
this.sendChatMsg(input, false, true);
}
}
rl.prompt();
});
rl.on("SIGINT", () => {
this.logger.error(strings.format(this, "NO_ABORT"));
return;
});
this.readlineInitialized = true;
this.logger.verbose(strings.format(this, "CLI_ACCEPTING_INPUT"));
}
/**
* Adds or removes a user to/from the user blacklist.
*
* @param {!string} name Username
* @param {?boolean=} state If true, adds the user to the blacklist; otherwise removes the user.
* @return {boolean} True if the user is blacklisted when removing, or if the user is not blacklisted when adding; false otherwise.
*/
Bot.prototype.setUserBlacklistState = function(name, state) {
name = name.toLowerCase();
let blacklistIndex = this.settings.userBlacklist.indexOf(name);
if (!~blacklistIndex && state) {
this.settings.userBlacklist.push(name);
} else if (~blacklistIndex && !state) {
utils.unsortedRemove(this.settings.userBlacklist, blacklistIndex);
} else {
return false;
}
this.writeSettings();
return true;
}
/**
* Sets an option within a user's persistent data (settings.json)
*
* @param {!string} username Username
* @param {!string} opt Key within the user's data to modify
* @param {!*} data Data to store
* @return {Object} Returns the user's persistent data
*/
Bot.prototype.setUserDataOpt = function(username, opt, data) {
username = username.toLowerCase();
if (!this.settings.userData.hasOwnProperty(username)) this.settings.userData[username] = {};
this.settings.userData[username][opt] = data;
this.writeSettings();
return this.settings.userData[username];
}
/**
* Stops the lead timer and starts it again. Calls sendVideoUpdate every 5 seconds. Requires bot to be leader.
* @see {@link Bot#sendVideoUpdate}
*/
Bot.prototype.startLeadTimer = function() {
this.stopLeadTimer();
this.logger.debug("startLeadTimer called");
if (this.leader) {
this.logger.debug("bot is leader, starting timer");
this.leadTimer = setInterval(this.sendVideoUpdate.bind(this), 5000);
}
}
/**
* Stops all timers and empties the duel list and all queues. Requires the bot to be killed unless force is true.
*
* @param {?boolean=} force If force, will disregard the bot's killed state
*/
Bot.prototype.stopAllTimers = function(force) {
if (!force && !this.killed) return this.logger.error(strings.format(bot, "STOPALLTIMERS_FAIL"));
this.stopLeadTimer();
clearTimeout(this.timeouts.minuteTick);
this.actionQueue.clearQueue();
this.broadcastPMQueue.clearQueue();
this.largeDataReqQueue.clearQueue();
let i = 0;
for (;i < this.duels.length; i++) {
clearTimeout(this.duels[i][2]);
}
for (var j in this.timeouts) {
clearTimeout(this.timeouts[j]);
}
this.duels = [];
}
/**
* Clears the lead timer interval and sets it to null.
*/
Bot.prototype.stopLeadTimer = function() {
this.logger.debug("stopLeadTimer called");
if (this.leadTimer) {
clearInterval(this.leadTimer);
this.leadTimer = null;
}
}
/**
* Does not emit a ban frame, but adds a user to the bot's timeBans list with the given time. If the user is already timebanned, this will overwrite their existing time.
*
* @param {!string} name Username
* @param {!number} time Time in seconds
*/
Bot.prototype.timeBan = function(name, time) {
if (time < 60 || !time) time = 60;
let delta = Date.now() + (time*1000),
i = 0,
bans = this.settings.timeBans[this.CHANNEL.room],
found = false;
for (;i < bans.length && !found; i++) {
if (bans[i].name === name) {
bans[i].unbanTime = delta;
found = true;
break;
}
}
if (!found)
bans.push({name: name, unbanTime: delta});
this.writeSettings();
}
/**
* Checks if the given user has at least one video in the playlist.
*
* @param {!string} username Username
* @return {boolean} True if the user has a video in the playlist; otherwise false
*/
Bot.prototype.touchUserVideos = function(username) {
username = username.toLowerCase();
let pl = this.CHANNEL.playlist,
i = 0;
for (;i < pl.length;i++) {
if (pl[i].queueby.toLowerCase() === username) {
return true;
}
}
return false;
}
/**
* Checks if a given username is associated with a known bot (list is found in the configuration file)
*
* @param {!object|string} user User object or username
* @return {boolean} True if username found within the bot list or same as this bot; false otherwise
*/
Bot.prototype.userIsBot = function(user) {
let name = null;
if (utils.isObject(user) && user.name) name = user.name; //lol
else name = user;
name = name.toLowerCase();
if (name === this.username.toLowerCase()) return true;
let i = 0;
for (;i < this.cfg.misc.bots.length; i++) {
if (this.cfg.misc.bots[i].toLowerCase() === name) {
return true;
}
}
return false;
}
/**
* Checks if a user is under a timeban.
*
* @param {string} name Username
* @return {boolean} True if the user is found in the timeBans list; otherwise false
*/
Bot.prototype.userIsTimebanned = function(name) {
name = name.toLowerCase();
let tb = this.settings.timeBans[this.CHANNEL.room],
i = 0;
for (;i < tb.length;i++) {
if (tb[i].name === name) {
return tb[i];
}
}
return false;
}
/**
* Unbans a given user if they are found in the banlist (requires rank 3+)
*
* @param {string} name Username to unban
* @return {boolean} True if bot has permissions and succeeds in sending an unban request; otherwise false
*/
Bot.prototype.unbanUser = function(name) {
name = name.toLowerCase();
let bl = this.CHANNEL.banlist,
tb = this.settings.timeBans[this.CHANNEL.room];
if (bl.length <= 0 || !this.checkChannelPermission("ban")) return false;
let i = bl.length-1, foundBan = false;
for (;i >= 0;i--) {
if (bl[i].name.toLowerCase() === name) {
let name = bl[i].name,
id = bl[i].id;
let fn = ()=>{this.socket.emit("unban", {name: name, id: id})};
this.actionQueue.enqueue([this, fn, []]);
foundBan = true;
}
}
return foundBan;
}
/**
* Unmutes the specified user.
*
* @param {string} username Username to unmute
* @return {boolean} True if muting conditions pass, false otherwise
*/
Bot.prototype.unmuteUser = function(username) {
if (this.checkChannelPermission("mute") && username && username.toLowerCase() !== this.username.toLowerCase()) {
let user = this.getUser(username);
if (user && (user.meta.muted || user.meta.smuted) && this.rank > user.rank) {
this.sendChatMsg("/unmute " + user.name, false, true);
return true;
}
}
return false;
}
/**
* Updates a user's roomtime, AFK time, and last seen in the DB
*
* @param {!string} name Username
* @param {?boolean=} doAfk If true, will also update AFK time
* @param {?function=} cb If specified, will be called when the DB action is complete
* @return {Promise} Promise, resolved with false if no user found or DB is not active; otherwise true
*/
Bot.prototype.updateUserRoomTime = function(name, doAfk, cb) {
let user = this.getUser(name);
if (!(this.cfg.db.use && this.cfg.db.useTables.users)) return Promise.resolve(false);
if (!user) {if (cb) cb(); return Promise.resolve(false)};
return new Promise((res)=>{
if (!this.db || this.CHANNEL.users.length <= 0) return res(false);
var now = Date.now();
var time = now - user.lastRoomtimeCheck;
user.lastRoomtimeCheck = now;
var afkTime = 0;
if (doAfk) {
if (user.timeWentAFK > 0) afkTime = now - user.timeWentAFK;
if (user.meta.afk) user.timeWentAFK = now;
else user.timeWentAFK = -1;
if (afkTime < 0) afkTime = 0;
}
if (time < 0) time = 0;
this.db.run("updateUserRoomTime", [time/1000, afkTime/1000, user.name], ()=> {
if (cb) cb();
res(true);
})
});
}
/**
* Updates room and AFK times of all users in the room in the database.
*
* @return {Promise} Returns a Promise, resolved with true once the DB action is finished
*/
Bot.prototype.updateUserRoomTimeAll = function() {
return new Promise((res)=>{
if (!(this.cfg.db.use && this.cfg.db.useTables.users)) {
res(false);
} else {
//this.logger.debug("Running batch updateUserRoomTime query...");
var users = this.CHANNEL.users;
var now = Date.now(),
times = [];
let i = 0;
for (; i < users.length; i++) {
var data = new Array(3);
var userObj = users[i];
data[0] = userObj.name;
var time = (now - userObj.lastRoomtimeCheck) / 1000;
data[1] = time < 0 ? 0 : time;
var afkTime = 0;
if (userObj.timeWentAFK > 0)
afkTime = (now - userObj.timeWentAFK) / 1000;
data[2] = afkTime < 0 ? 0 : afkTime;
userObj.lastRoomtimeCheck = now;
if (userObj.meta.afk)
userObj.timeWentAFK = now;
else
userObj.timeWentAFK = -1;
if (data[1] > 0 || data[2] > 0)
times.push(data);
}
this.db.run("updateUserRoomTimeAll", times, ()=>{
this.logger.debug("Done! (batch active)");
res(true);
});
}
});
}
/**
* Checks if a user has a channel permission.
*
* @param {!string} perm Channel permission to check
* @param {!number} userRank A user's rank
* @return {boolean} True if user has permission, false otherwise
*/
Bot.prototype.userHasChannelPermission = function(perm, userRank) {
if (this.first.grabbedPermissions) return false;
var userRank_ = parseInt(userRank);
if (!this.CHANNEL.playlistIsLocked && /^playlist/.test(perm) && this.CHANNEL.perms.hasOwnProperty("o" + perm)) perm = "o" + perm;
if (!isNaN(userRank_) && this.CHANNEL.perms.hasOwnProperty(perm)) {
return userRank_ >= this.CHANNEL.perms[perm];
} else if (!this.CHANNEL.perms.hasOwnProperty(perm)) {
this.logger.error(strings.format(this, "PERMISSION_NOT_FOUND", [perm]));
}
return false;
}
/**
* Checks if the given rank is equal to or above USER (default 1), or below it if allowGuestData is true.
*
* @param {!number} rank User's rank
* @return {boolean} True if the DB is allowed to store the user's data
*/
Bot.prototype.userRankDBCheck = function(rank) {
return rank >= this.RANKS.USER || (rank < this.RANKS.USER && this.cfg.db.allowGuestData);
}
/**
* Checks the given string among valid trigger characters and returns ! if invalid.
*
* @param {!string} trigger A string of length 1
* @return {string} Returns given trigger if valid, otherwise !
*/
Bot.prototype.validateTrigger = function(trigger) {
if (typeof trigger !== "string" || trigger.length !== 1 || !~'!#$%^&*()_+-=`~.,?'.indexOf(trigger)) {
this.logger.warn(strings.format(this, "TRIGGER_INVALID", ["!"]));
return '!';
} else {
return trigger;
}
}
/**
* Writes text to the terminal and to a stream.
*
* @param {!string} out Text to be displayed/written
* @param {?fs.WriteStream} stream WriteStream to write out to
* @param {?boolean=} gotoBotLog If true, will go to the standard bot.log stream (will cause duplicate lines if used with the standard log stream!)
*/
Bot.prototype.write = function(out, stream, gotoBotLog) {
if (stream.destroyed) return;
if (stream) {
stream.write(C.strip(out) + "\r\n");
if (gotoBotLog)
this.getLogStream().write(C.strip(out) + "\r\n");
}
//clear the entire line, write output text, reset prompt
process.stdout.write("\r\x1B[K"+out+"\n");
this.rl._refreshLine();
}
/**
* Writes bot.settings into settings.json or settings-ROOMNAME.json, depending on config. Uses fs.writeFileSync.
*/
Bot.prototype.writeSettings = function() {
this.logger.verbose(strings.format(this, "SETTINGS_WRITE"));
var settings = JSON.stringify(this.settings);
try {
fs.writeFileSync(path.join(this.ROOTPATH, this.settingsFile), settings, "utf8");
} catch (err) {
this.logger.error(strings.format(this, "FILE_WRITE_ERROR", [this.settingsFile, err.stack]));
}
};
module.exports = {
"init": function(config, readline, ROOT) {
let bot = new Bot(config, readline, ROOT);
bot.db.init(bot);
return bot;
}
}
/* "Default" (? grabbed from a clean room) permissions
{
//OPEN PLAYLIST
"oplaylistadd":-1, //Add to playlist
"oplaylistnext":1.5, //Add/move to next
"oplaylistmove":1.5, //Move playlist items
"oplaylistdelete":2, //Delete playlist items
"oplaylistjump":1.5, //Jump to video
"oplaylistaddlist":1.5, //Queue playlist
//GENERAL PLAYLIST
"seeplaylist":-1, //View the playlist
"playlistadd":1.5, //Add to playlist
"playlistnext":1.5, //Add/move to next
"playlistmove":1.5, //Move playlist items
"playlistdelete":2, //Delete playlist items
"playlistjump":1.5, //Jump to video
"playlistaddlist":1.5, //Queue playlist
"playlistaddcustom":3, //Embed custom media
"playlistaddrawfile":2, //Add raw video file
"playlistaddlive":1.5, //Queue livestream
"exceedmaxlength":2, //Exceed maximum media length
"exceedmaxdurationperuser":2 //Exceed maximum total media length
"addnontemp":2, //Add nontemporary media
"settemp":2, //Temp/untemp playlist item
"playlistshuffle":2, //Shuffle playlist
"playlistclear":2, //Clear playlist
"exceedmaxitems":2, //Exceed maximum number of videos per user
"deletefromchannellib":2, //Delete from channel library
"playlistlock":2, //Lock/unlock playlist
//POLLS
"pollctl":1.5, //Open/Close Poll
"pollvote":-1, //Vote
"viewhiddenpoll":1.5, //View hidden poll results
"voteskip":-1, //Voteskip
"viewvoteskip":1.5, //View voteskip results
//MODERATION
"mute":1.5, //Mute users
"kick":1.5, //Kick users
"ban":2, //Ban users
"motdedit":3, //Edit MOTD
"filteredit":3, //Edit chat filters
"filterimport":3, //Import chat filters
"emoteedit":3, //Edit chat emotes
"emoteimport":3, //Import chat emotes
"leaderctl":2, //Assign/Remove leader
//MISC
"drink":1000000, //Drink calls
"chat":0, //Chat
"chatclear":2, //Clear Chat
}
*/