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

793 lines
23 KiB
JavaScript

"use strict";
const C = require("cli-color");
const strings = require("./strings.js");
var spaceReg = new RegExp("\\s+", "gm");
/**
* Object of public functions for performing various miscellaneous stuff
* @namespace utils
*/
var utils = module.exports = {
/**
* Colors a media title based on its source. Requires colorMediaTitles to be enabled.
* @memberof utils
* @param {!Bot} bot Bot object
* @param {!string} type Media type (host abbreviation)
* @param {!string} title Title to color
* @return {string} Colored title
*/
colorMediaTitle: function(bot, type, title) {
if (bot.cfg.interface.colorMediaTitles) {
switch (type) {
case "yt":
return C.redBright(title);
case "li":
case "vi":
return C.blueBright(title);
case "dm":
return C.yellowBright(title);
case "sc":
return C.yellow(title);
case "tw":
case "tc":
return C.magentaBright(title);
case "im":
return C.greenBright(title);
case "us":
case "hb":
return C.blue(title);
case "gd":
case "sb":
return C.cyanBright(title);
default:
return C.whiteBright(title);
}
}
return title;
},
/**
* Colors a username based on the user's rank. Requires colorUsernames.
* @memberof utils
* @param {!Bot} bot Bot object
* @param {!string|Object} username Username or user object to color
* @return {string|Object} Colored username, or object if given and invalid
*/
colorUsername: function(bot, user) {
if (!user) {
bot.logger.error(strings.format(bot, "COLORNAME_NONAME"));
return strings.format(bot, "NO_USERNAME");
}
if (bot.cfg.interface.colorUsernames) {
let _user = null;
if (typeof user === "string") {
if (user.indexOf("[") >= 0)
return C[bot.cfg.interface.rankColors.server](user);
else if (user === "(anon)")
return C[bot.cfg.interface.rankColors.anonymous](user);
_user = bot.getUser(user);
if (!_user) {
return user;
}
} else if (utils.isObject(user) && user.hasOwnProperty("name") && user.hasOwnProperty("rank")) {
_user = user;
}
if (!_user) return strings.format(bot, "NO_USERNAME");
if (_user.rank < 1 || (_user.meta && _user.meta.guest))
return C[bot.cfg.interface.rankColors.unregistered](_user.name);
else if (_user.rank >= 1 && bot.cfg.interface.rankColors.hasOwnProperty(_user.rank)) {
if (_user.rank >= bot.RANKS.SITEOWNER)
return C.magentaBright(_user.name);
else
return C[bot.cfg.interface.rankColors[_user.rank]](_user.name);
}
return C.whiteBright(_user.name);
}
return user;
},
/**
* Compares two arrays and checks if the contents are identical. Order does not matter.
* @memberof utils
* @param {!any[]} arrA Array A
* @param {!any[]} arrB Array B
* @return {boolean} True if arrays are the same, false otherwise.
*/
compareArrays: function(arrA, arrB) {
if (Array.isArray(arrA) && Array.isArray(arrB)) {
if (arrA.length === arrB.length) {
for (var i = 0; i < arrA.length; i++) {
var eq = false;
if (Array.isArray(arrA[i])) {
for (var j = 0; j < arrB.length && !eq; j++) {
if (Array.isArray(arrB[j])) {
if (utils.compareArrays(arrA[i], arrB[j])) eq = true;
}
}
if (!eq) return false;
} else if (utils.isObject(arrA[i])) {
for (var j = 0; j < arrB.length && !eq; j++) {
if (utils.isObject(arrB[j])) {
if (utils.compareObjects(arrA[i], arrB[j])) eq = true;
}
}
if (!eq) return false;
} else if (!~arrB.indexOf(arrA[i])) {
return false;
}
}
return true;
}
}
return false;
},
/**
* Compares two objects and checks if contents are identical.
* @memberof utils
* @param {!Object} objA Object A
* @param {!Object} objB Object B
* @return {boolean} True if objects are the same, false otherwise.
*/
compareObjects: function(objA, objB) {
if (utils.isObject(objA) && utils.isObject(objB)) {
if (Object.keys(objA).length !== Object.keys(objB).length) {
return false;
}
for (var i in objA) {
if (objB.hasOwnProperty(i)) {
//if values are arrays
if (Array.isArray(objA[i]) && Array.isArray(objB[i])) {
if (!utils.compareArrays(objA[i], objB[i])) return false;
}
//if values are objects
else if (utils.isObject(objA[i]) && utils.isObject(objB[i])) {
if (!utils.compareObjects(objA[i], objB[i])) return false;
}
//otherwise if unequal
else if (objA[i] !== objB[i])
return false;
} else {
return false;
}
}
return true;
}
return utils.compareArrays(objA, objB);
},
/**
* Colors emotes in a message for the CLI. If enabled and uname is given, inserts the usage into the database.
* From CyTube's source.
* {@link https://github.com/calzoneman/sync/blob/f081bc782adba074052884995b90bc77dcef3338/www/js/util.js#L2730}
* @memberof utils
* @param {!Bot} bot Bot object
* @param {!string} msg Entire message
* @param {?string=} uname Sender's username
* @return {string} Message with colored emotes
*/
execEmotes: function (bot, msg, uname) {
if (!bot.cfg.chat.parseEmotes) {
return msg;
}
if (uname) uname = C.strip(uname);
var count = 0,
noLimit = bot.cfg.chat.maxEmotes < 0,
counts = {};
function foundEmote(name) {
count++;
if (!noLimit && count > bot.cfg.chat.maxEmotes) {
return C.blackBright(name);
} else {
countEmote(name);
}
return C.cyan(name);
}
function countEmote(emote) {
if (!counts.hasOwnProperty(emote)) counts[emote] = 1;
else counts[emote] += 1;
}
bot.CHANNEL.badEmotes.forEach(function (e) {
msg = msg.replace(e.regex, function (){
return foundEmote(e.name);
});
});
msg = msg.replace(/[^\s]+/gi, function (m) {
var _m = m.toLowerCase();
if (bot.CHANNEL.emoteMap.hasOwnProperty(_m)) {
var e = bot.CHANNEL.emoteMap[_m];
return foundEmote(e.name);
} else {
return m;
}
});
if (count > 0 && uname && bot.db && bot.cfg.db.useTables.users && bot.cfg.db.useTables.emote_data && !bot.getSavedUserData(uname).quoteExempt && Object.keys(counts).length > 0) {
var values = [];
for (var i in counts) {
values.push([uname, i, counts[i]]);
}
bot.db.run("updateEmoteCounts", values, function() {
})
}
return msg;
},
/**
* Creates a full link from a media type and ID, or a shortened link.
* Most of this comes from CyTube's source.
* {@link https://github.com/calzoneman/sync/blob/db48104b80f5713e33e91badb82626fe1ea278b7/src/utilities.js#L180}
* @memberof utils
* @param {number} id Media ID
* @param {string} type Media type
* @param {?boolean=} short If true, will shorten the media source to type:id
* @return {string} Full or shortened link
*/
formatLink: function (id, type, short) {
if (!type || !id) return "";
if (short) return type + ":" + id;
switch (type) {
case "yt":
return "https://youtu.be/" + id;
case "vi":
return "https://vimeo.com/" + id;
case "dm":
return "https://dailymotion.com/video/" + id;
case "sc":
return id;
case "li":
return "https://livestream.com/" + id;
case "tw":
return "https://twitch.tv/" + id;
case "rt":
return id;
case "im":
return "https://imgur.com/a/" + id;
case "us":
return "https://ustream.tv/channel/" + id;
case "gd":
return "https://docs.google.com/file/d/" + id;
case "fi":
return id;
case "hb":
return "https://www.smashcast.tv/" + id;
case "hl":
return id;
case "sb":
return "https://streamable.com/" + id;
case "tc":
return "https://clips.twitch.tv/" + id;
case "cm":
return id;
default:
return "";
}
},
/**
* Extracts the hostname from a URL.
* @memberof utils
* @param {!string} link Full link
* @return {string} Hostname
*/
getHostname: function(link) {
var matches = link.match(/^(?:https?\:\/\/)(?:.+?\.)*?([^\.\/]*?\.[^\.\/]*?)(?:[\:\/]|$)/i);
if (!matches) return "";
return matches[1];
},
/**
* Gets the current time (or provided time) as a timestamp.
* @memberof utils
* @param {?boolean} twentyfour If true, uses 24h time instead of AM/PM
* @param {?number=} time Epoch time in milliseconds
* @return {string} Timestamp
*/
getTimestamp: function(twentyfour, time) {
var date = time ? new Date(time) : new Date(),
now = {
M: date.getMonth()+1,
D: date.getDate(),
Y: date.getFullYear().toString().substr(-2),
h: date.getHours(),
m: date.getMinutes(),
s: date.getSeconds(),
P: "a"
};
if (twentyfour) {
now.P = "";
} else {
if (now.h >= 12) {
now.P = "p";
if (now.h > 12)
now.h -= 12;
} else if (now.h === 0) {
now.h = 12;
}
}
if (now.m < 10) now.m = "0" + now.m;
if (now.s < 10) now.s = "0" + now.s;
return "[" + now.M + "/" + now.D + "/" + now.Y + " " + now.h + ":" + now.m + ":" + now.s + now.P + "]";
},
/**
* Gets a date string using the current UTC time, or a provided time.
* @memberof utils
* @see {@link getUTCTimeStringFromDate}
* @see {@link getUTCTimestamp}
* @param {?number=} date Epoch time in milliseconds
* @return {string} Date string
*/
getUTCDateStringFromDate: function(date) {
date = date ? new Date(date) : new Date();
return date.toUTCString().split(" ").splice(1,3).join(" ");
},
/**
* Gets a date and time string using the current UTC time or a provided time.
* @memberof utils
* @see {@link getUTCDateStringFromDate}
* @see {@link getUTCTimestamp}
* @param {?number=} date Epoch time in milliseconds
* @return {string} Date+time string
*/
getUTCTimeStringFromDate: function(date) {
date = date ? new Date(date) : new Date();
return date.toUTCString().split(" ").splice(1,4).join(" ") + " UTC";
},
/**
* Gets a simple time string using the current UTC time or a provided time.
* @memberof utils
* @see {@link getUTCDateStringFromDate}
* @see {@link getUTCTimeStringFromDate}
* @param {?number=} time Epoch time in milliseconds
* @return {string} Time string
*/
getUTCTimestamp: function(time) {
var now = time ? new Date(time) : new Date();
return now.getUTCHours() + ":" +
(now.getUTCMinutes() > 9 ? now.getUTCMinutes() : "0" + now.getUTCMinutes()) + ":" +
(now.getUTCSeconds() > 9 ? now.getUTCSeconds() : "0" + now.getUTCSeconds())
},
/**
* Checks if a string contains the beginning of a valid inline command.
* @memberof utils
* @param {!string} trig Bot trigger
* @param {!string} str Message
* @return {string} Empty if no match, or everything after the match
*/
inlineCmdCheck:function(trig, str) {
let i = str.indexOf("\:\:" + trig);
if (~i) {
return str.substr(i + 2 + trig.length);
}
return "";
},
/**
* Checks if something is an Object and not an array.
* @memberof utils
* @param {*} item Input to check
* @return {boolean} True if object, false otherwise
*/
isObject: function(item) {
return (item instanceof Object && !Array.isArray(item));
},
/**
* Checks if a whole string is a number (int or decimal), but doesn't parse it.
* @memberof utils
* @param {!string} num Number string
* @return {boolean} True if pattern matched, otherwise false
*/
isNumber:function(num) {
return /^\d+(?:\.(?:\d+)?)?$/.test(num);
},
/**
* Checks if a username is valid according to CyTube. Code from CyTube.
* {@link https://github.com/calzoneman/sync/blob/f081bc782adba074052884995b90bc77dcef3338/src/utilities.js#L10}
* @memberof utils
* @param {!string} name Username
* @return {boolean} True if username is valid, false otherwise
*/
isValidUserName: function(name) {
return name.match(/^[\w-]{1,20}$/);
},
/**
* Checks if a string is in an IPv4 format, but very loosely. Octets can be >255
* @memberof utils
* @param {!string} str IP string
* @return {boolean} True if IPv4, false otherwise
*/
looseIPTest:function(str) {
return /(\d{1,3}\.){3}\d{1,3}/.test(str);
},
/**
* Parses a boolean from different datatypes.
* @memberof utils
* @param {boolean|number|string} bool Input to be parsed
* @return {boolean|null} True/false if valid input. Null otherwise
*/
parseBool: function(bool) {
if (typeof bool === "boolean") return bool;
if (typeof bool !== "string") {
if (typeof bool === "number") return !!bool;
return null;
}
//check these individually and explicitly because null lets us know if the input is bad
if (bool.toLowerCase() === "true") return true;
if (bool.toLowerCase() === "false") return false;
return null;
},
/**
* Checks if a given image link matches one of the specified link patterns. Used for validation.
* @memberof utils
* @param {string} str Input link
* @return {boolean|string} False if not HTTPS, a string, or a valid pattern; otherwise returns the URL without the protocol
*/
parseImageLink: function(str) {
if (typeof str !== "string") return false;
str = str.trim();
if (str.toLowerCase().indexOf("https://") !== 0) return false;
let exp = [
/*discord*/ /(?:cdn\.discordapp\.com|media\.discordapp\.net)\/attachments\/\d+\/\d+\/[^\s\0\\\/\:\*\?\"\<\>\|]+\.(?:jpe?g|gif|png|webp)/gi,
/*4chan*/ /i(?:s)?(?:\d)?\.(?:4cdn|4chan)\.org\/\w{1,6}\/\d{8,15}\.(?:jpe?g|gif|png|webp)/gi,
/*gyazo*/ /i\.gyazo\.com\/\w+\.(?:jpe?g|gif|png|webp)/gi,
/*tumblr*/ /(?:\d+\.)?(?:static|media)\.tumblr\.com\/(?:\w+\/)*tumblr_\w+(?:\_\w+)?\.(?:jpe?g|gif|png|webp)/gi,
/*puush*/ /puu\.sh\/\w+\/\w+\.(?:jpe?g|gif|png|webp)/gi,
/*leddit*/ /i\.redd\.it\/\w+\.(?:jpe?g|gif|png|webp)/gi,
/*gfycat*/ /giant\.gfycat\.com\/\w+\.gif/gi
];
let match = null,
i = 0;
for (;i < exp.length;i++) {
match = str.match(exp[i]);
if (match) return "https://" + match[0];
}
return false;
},
/**
* Creates a data object to be used with queueing videos.
* Directly from CyTube source.
* {@link https://github.com/calzoneman/sync/blob/bd63013524d06f25258aab054d150325a4b91e10/www/js/util.js#L1287}
* @memberof utils
* @param {string} url Media URL
* @return {Object|null} Data object, or null if parsing failed
*/
parseMediaLink: function(url) {
if(typeof url != "string") {
return {
id: null,
type: null
};
}
url = url.trim();
url = url.replace("feature=player_embedded&", "");
//this also comes from CyTube
function extractQueryParam(query, param) {
var params = {};
query.split("&").forEach(function (kv) {
kv = kv.split("=");
params[kv[0]] = kv[1];
});
return params[param];
}
if(url.indexOf("rtmp://") == 0) {
return {
id: url,
type: "rt"
};
}
var m;
if((m = url.match(/youtube\.com\/watch\?([^#]+)/))) {
return {
id: extractQueryParam(m[1], "v"),
type: "yt"
};
}
// YouTube shorts
if((m = url.match(/youtube\.com\/shorts\/([a-zA-Z0-9_-]{11})/))) {
return {
id: m[1],
type: "yt"
};
}
if((m = url.match(/youtu\.be\/([^\?&#]+)/))) {
return {
id: m[1],
type: "yt"
};
}
if((m = url.match(/youtube\.com\/playlist\?([^#]+)/))) {
return {
id: extractQueryParam(m[1], "list"),
type: "yp"
};
}
if ((m = url.match(/clips\.twitch\.tv\/([A-Za-z]+)/))) {
return {
id: m[1],
type: "tc"
};
}
// #790
if ((m = url.match(/twitch\.tv\/(?:.*?)\/clip\/([A-Za-z]+)/))) {
return {
id: m[1],
type: "tc"
}
}
if((m = url.match(/twitch\.tv\/(?:.*?)\/([cv])\/(\d+)/))) {
return {
id: m[1] + m[2],
type: "tv"
};
}
/**
* 2017-02-23
* Twitch changed their URL pattern for recorded videos, apparently.
* https://github.com/calzoneman/sync/issues/646
*/
if((m = url.match(/twitch\.tv\/videos\/(\d+)/))) {
return {
id: "v" + m[1],
type: "tv"
};
}
if((m = url.match(/twitch\.tv\/([\w-]+)/))) {
return {
id: m[1],
type: "tw"
};
}
if((m = url.match(/livestream\.com\/([^\?&#]+)/))) {
return {
id: m[1],
type: "li"
};
}
if((m = url.match(/ustream\.tv\/([^\?&#]+)/))) {
return {
id: m[1],
type: "us"
};
}
if ((m = url.match(/(?:hitbox|smashcast)\.tv\/([^\?&#]+)/))) {
return {
id: m[1],
type: "hb"
};
}
if((m = url.match(/vimeo\.com\/([^\?&#]+)/))) {
return {
id: m[1],
type: "vi"
};
}
if((m = url.match(/dailymotion\.com\/video\/([^\?&#_]+)/))) {
return {
id: m[1],
type: "dm"
};
}
if((m = url.match(/soundcloud\.com\/([^\?&#]+)/))) {
return {
id: url,
type: "sc"
};
}
if ((m = url.match(/(?:docs|drive)\.google\.com\/file\/d\/([a-zA-Z0-9_-]+)/)) ||
(m = url.match(/drive\.google\.com\/open\?id=([a-zA-Z0-9_-]+)/))) {
return {
id: m[1],
type: "gd"
};
}
if ((m = url.match(/(.*\.m3u8)/))) {
return {
id: url,
type: "hl"
};
}
if((m = url.match(/streamable\.com\/([\w-]+)/))) {
return {
id: m[1],
type: "sb"
};
}
/* Shorthand URIs */
// So we still trim DailyMotion URLs
if((m = url.match(/^dm:([^\?&#_]+)/))) {
return {
id: m[1],
type: "dm"
};
}
// Raw files need to keep the query string
if ((m = url.match(/^fi:(.*)/))) {
return {
id: m[1],
type: "fi"
};
}
if ((m = url.match(/^cm:(.*)/))) {
return {
id: m[1],
type: "cm"
};
}
// Generic for the rest.
if ((m = url.match(/^([a-z]{2}):([^\?&#]+)/))) {
return {
id: m[2],
type: m[1]
};
}
/* Raw file */
var tmp = url.split("?")[0];
if (tmp.match(/^https?:\/\//)) {
if (tmp.match(/\.json$/)) {
// Custom media manifest format
return {
id: url,
type: "cm"
};
} else {
// Assume raw file (server will check)
return {
id: url,
type: "fi"
};
}
}
//null if parsing didn't work
return null;
},
/**
* Replaces all whitespace in a string with a single space, including newlines and zero-width spaces
* @memberof utils
* @param {!string} str Input string
* @return {string} String without excess whitespace
*/
removeExcessWhitespace: function(str) {
return str.replace(spaceReg, " ");
},
/**
* Loosely removes anything encased with < and >
* @memberof utils
* @param {!string} str Input string
* @return {string} String without tags
*/
removeHtmlTags: function(str) {
return str.replace(/(\<.+?\>)+/gi, "");
},
/**
* Converts a number into a timecode.
* @memberof utils
* @param {?number} num The input to convert
* @param {boolean=} letters If true, will output with dhms instead of :
* @return {string} Timecode string
*/
secsToTime: function(num, letters) {
if (undefined==num) num = 0;
else num = Math.floor(num);
var days = Math.floor(num/(3600*24));
var hours = Math.floor(num/3600) % 24;
var minutes = Math.floor(num / 60) % 60;
var seconds = num % 60;
if (hours < 10 && days > 0)
hours = "0" + hours;
if (minutes < 10 && (hours > 0 || days > 0))
minutes = "0" + minutes;
if (seconds < 10)
seconds = "0" + seconds;
var time = "";
if (letters) {
if (days != 0)
time += days + "d";
if (hours != 0)
time += hours + "h";
if (minutes != 0)
time += minutes + "m";
if (seconds != 0)
time += seconds + "s";
if (time === "") return "0s";
} else {
if (days != 0)
time += days + ":" + hours + ":";
else if (hours != 0)
time += hours + ":";
time += minutes + (letters ? "m" : ":") + seconds + (letters ? "s" : "");
}
return time;
},
/**
* Converts [H:]M:S to seconds. Used with user input, so -1 handles invalid input.
* @memberof utils
* @param {!string} timecode A timecode expected to consist of [H:]M:S
* @return {number} Returns -1 if invalid input, or amount of seconds represented by the timecode.
*/
timecodeToSecs: function(timecode) {
let matches = [...timecode.matchAll(/^(\d+\:)?(\d{1,2}\:)(\d{2})$/g)][0];
if (!matches) return -1;
let secs = 0;
if (matches[1]) secs += (parseInt(matches[1]) * 60 * 60);
if (matches[2]) secs += (parseInt(matches[2]) * 60);
if (matches[3]) secs += parseInt(matches[3]);
return secs;
},
/**
* Cuts the first and last chars from a string.
* @memberof utils
* @param {!string} str Input string
* @return {string} Returns str without the first and last characters
*/
trimStringEnds: function(str) {
return str.substr(1, str.length - 2);
},
/**
* Removes an item from an unsorted array by putting the last item on the given index and reducing the array's length by 1. ONLY USE ON UNSORTED ARRAYS
* @memberof utils
* @param {any[]} arr Input array
* @param {number} i Index of item to remove
* @return {boolean} False if index out of bounds, otherwise true.
*/
unsortedRemove: function(arr, i) {
if (i < 0 || i >= arr.length) return false;
if (i < arr.length-1)
arr[i] = arr[arr.length-1];
arr.length--;
return true;
}
}