init commit

This commit is contained in:
rainbownapkin 2021-12-06 19:59:30 -05:00
parent f1edb29c97
commit 3cdfb88ae3
28 changed files with 9406 additions and 78 deletions

4
lib/.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
*/
*-hidden*
!.gitignore

251
lib/api.js Normal file
View file

@ -0,0 +1,251 @@
const fetch = require("node-fetch");
const strings = require("./strings.js");
const utils = require("./utils.js");
/*
Each API item must be a function with the follow parameters:
bot: Bot object
msg: A message from a chat command or something similar. Nullable.
apiKey: A string containing an API key if needed. Nullable.
callback: A function that does something with the status and data returned,
see below. When it comes to chat commands, the command provides the callback
here.
Once the request is done, the callback given to sendRequest is called if present,
and it should have the following parameters:
status: Status code of the request. Some of these API methods here change
the actual status code if it's not OK (yeah it's strange but some
commands use this).
data: The data. If json is true, this will be a proper object.
ok: A boolean determining if the status was OK.
Inside the callback in sendRequest, the callback for the API method is then called.
*/
var APIs = {
"anagram": function (bot, msg, apiKey, callback) {
var url = "https://anagramgenius.com/server.php?source_text=" + encodeURIComponent(msg) + "&vulgar=1";
var json = false;
var options = {
method: "GET",
timeout: 1000
}
sendRequest(bot, url, json, options, "anagram", function (status, data, ok) {
var _data = data.match(/anagrams to\<br\>\<span class=\"black\-18\">\'(.+?)\'\<\/span\>/);
if (!(_data && _data[1])) _data = null;
callback(status, _data, ok);
})
},
//Make sure you change the schedule ID for each new event. This will be outdated
"gdq": function(bot, msg, apiKey, callback) {
var url = "https://horaro.org/-/api/v1/schedules/3011nzb4yh71id7a63";
var json = true;
var options = {
method: "GET",
timeout: 1000
};
sendRequest(bot, url, json, options, "gdq", function(status, data, ok) {
callback(status, data, ok);
})
},
"saltybet": function (bot, msg, apiKey, callback) {
var url = "https://saltybet.com/state.json";
var json = true;
var options = {
method: "GET",
timeout: 1000
}
sendRequest(bot, url, json, options, "saltybet", function(status, data, ok) {
callback(status, data, ok);
})
},
//(not really a public API but valid endpoint)
"urbandictionary": function (bot, msg, apiKey, callback) {
var url = "https://api.urbandictionary.com/v0/define?term=" + encodeURIComponent(msg);
var json = true;
var options = {
method: "GET",
timeout: 2000
}
sendRequest(bot, url, json, options, "urbandictionary", function (status, data, ok) {
if (!ok) data = null;
callback(status, data, ok);
})
},
//YouTube v3: Comments
"youtubecomments": function (bot, videoId, apiKey, callback) {
if (apiKey.trim() === "") {
callback(-1, null, false);
return;
}
var url = "https://www.googleapis.com:443" +
"/youtube/v3/commentThreads?" +
"part=snippet&videoId=" + videoId + "&key=" + apiKey + "&maxResults=100&order=relevance";
var json = true;
var options = {
method: "GET",
timeout: 1500
}
sendRequest(bot, url, json, options, "youtubecomments", function (status, data, ok) {
if (status !== 200) {
if (status === 403) callback(status, data, ok);
else callback(status, null, ok)
return;
}
callback(true, data, ok);
});
},
//YouTube v3: PlaylistItems
//Returns a list of videos from a playlist
//Untested
/*
"youtubeplaylist": function (bot, data, apiKey, callback) {
let playlistId = data.playlistId,
maxResults = data.maxResults;
if (apiKey.trim() === "" || !playlistId || !maxResults) {
callback(-1, null);
return;
}
var url = "https://www.googleapis.com:443" +
"/youtube/v3/playlistItems?" +
"part=contentDetails&id=" + playlistId + "&key=" + apiKey + "&maxResults=" + maxResults;
var json = true;
var options = {
method: "GET",
timeout: 1500
}
sendRequest(bot, url, json, options, "youtubeplaylist", function(status, data, ok) {
if (status !== 200) {
if (status === 403) callback(status, data, ok);
else callback(status, null, ok)
return;
}
callback(true, data, ok);
});
},*/
//YouTube v3: Statistics
//Returns a statistic object with views, likes, etc
"youtubestatistics": function (bot, videoId, apiKey, callback) {
if (apiKey.trim() === "") {
callback(-1, null);
return;
}
var url = "https://www.googleapis.com:443" +
"/youtube/v3/videos?" +
"part=statistics&id=" + videoId + "&key=" + apiKey;
var json = true;
var options = {
method: "GET",
timeout: 1500
}
sendRequest(bot, url, json, options, "youtubestatistics", function(status, data, ok) {
if (status !== 200) {
if (status === 403) callback(status, data, ok);
else callback(status, null, ok)
return;
}
callback(true, data, ok);
});
},
/*
RETURNS:
status - Request status code. Can also be <0 for certain situations
-1: don't do anything. Invalid input or similar issue.
-2: Blocked input.
-3: Error or blocked output. Just log the data, don't send it
data - Response from wolfram, or error info. Empty if bad input.
*/
"wolfram": function (bot, query, apiKey, callback) {
if (apiKey.trim() === "" || !query || query.trim() === "") {
return callback(-1, null, false);
}
if (/nearme|ipv4|ipv6|latlong|latitude|longitude|rot13|base64|geoip|coordinat|whoami|whereami|ipaddress|location|myip|geograph|fromcharactercode|tocharactercode|bytearraytostring|characterrange|fromletternumber|alphabet|charactername|characterencoding|encod|decod|parse/i.test(query.replace(/\W/g, "")))
return callback(-2, "That query is not allowed", false);
var url = "https://api.wolframalpha.com/v1/result?i=" +
encodeURIComponent(query) + "&appid=" + apiKey;
var json = false;
var options = {
method: "GET",
timeout: 2000
}
sendRequest(bot, url, json, options, "wolfram", function(status, data, ok) {
if (utils.looseIPTest(data) || /.+, united states/i.test(data)) {
callback(-3, "Response blocked.", ok);
} else if (/error [\d\w]+\:/i.test(data)) {
callback(-3, data, ok);
}
else callback(status, data, ok);
});
}
}
/**
* Sends a fetch request to the desired endpoint.
*
* @param {!Bot} bot Bot object
* @param {!string} url Full url of the desired endpoint
* @param {?boolean} json Whether or not the data will be JSON. Data returned will automatically be converted from JSON if needed.
* @param {?Object} options Object of options, which should contain a HTTP request method and timeout at the very least.
* @param {!type} apiName Name of the API to use. Must match one of the keys of the APIs object
* @param {?type} callback Function to call after the request
*/
function sendRequest(bot, url, json, options, apiName, callback) {
if (!options.hasOwnProperty("headers")) options["headers"] = {};
if (json) {
options.headers["Content-Type"] = "application/json";
}
var body = "";
var status = -1;
var ok = false;
var fn = ()=>{
fetch(url, options)
.then(res=>{
status = res.status;
ok = res.ok;
if (!ok) {
bot.logger.error(strings.format(bot, "API_NOT_OK", [apiName, status, JSON.stringify(res.statusText)]))
}
return json ? res.json() : res.text();
})
.then(data=>{
if (callback)
callback(status, data, ok);
})
.catch(e=>{
if (apiName === "youtubestatistics") {
bot.gettingVideoMeta = false;
} else if (apiName === "youtubecomments") {
bot.gettingComments = false;
}
if (e.type === "request-timeout") {
bot.sendChatMsg(strings.format(bot, "API_TIMEOUT", [apiName]));
} else
bot.logger.error(strings.format(bot, "API_ERROR", [apiName, e.stack]));
});
}
bot.actionQueue.enqueue([this, fn, []]);
}
module.exports = {
APIs: APIs,
APIcall: function(bot, API, data, apiKey, callback) {
if (this.APIs.hasOwnProperty(API)) {
this.APIs[API](bot, data, apiKey, callback);
} else {
bot.logger.error(strings.format(bot, "API_NOT_FOUND", [API]));
}
}
}

2060
lib/bot.js Normal file

File diff suppressed because it is too large Load diff

2785
lib/chatcommands.js Normal file

File diff suppressed because it is too large Load diff

97
lib/classes.js Normal file
View file

@ -0,0 +1,97 @@
"use strict";
//queue items like this:
//AutoFnQueueObject.enqueue([this (context), fn_name, [argument0, argument1, ...]])
/**
* Class which allows automatic queueing of actions with a specified interval time.
* @constructor
* @param {number} interval Amount of time between actions in milliseconds.
*/
function AutoFnQueue(interval) {
if (typeof interval !== "number" || interval < 0) {
throw new TypeError("AutoFnQueue: interval must be a positive number!");
}
this.items = [];
this.interval = interval;
this.flushing = false;
this.intervalId = null;
}
/**
* Enqueue an item and begin flushing the queue, if not flushing already.
*
* @param {any[]} item Array consisting of context, the function to execute, and an array of arguments to apply to the function if needed, in that order.
*/
AutoFnQueue.prototype.enqueue = function(item) {
this.items.push(item);
this.flush();
}
/**
* Shifts the first item from the queue and returns it.
*
* @return {any[]} Queue item
*/
AutoFnQueue.prototype.dequeue = function() {
if (!this.isEmpty()) return this.items.shift();
}
/**
* Check if the queue is empty.
*
* @return {boolean} True if empty, false otherwise.
*/
AutoFnQueue.prototype.isEmpty = function() {
return this.items.length <= 0;
}
/**
* Gets the first item from the queue but does not remove it.
*
* @return {any[]} Queue item
*/
AutoFnQueue.prototype.peek = function() {
return this.items[0];
}
/**
* Begins flushing the queue if it has not been started already.
*/
AutoFnQueue.prototype.flush = function() {
if (this.flushing || this.isEmpty()) return;
this.flushing = true;
var item = this.dequeue();
item[1].apply(item[0], item[2]);
this.intervalId = setInterval(()=> {
if (this.isEmpty()) {
this.interrupt();
} else {
var item = this.dequeue();
item[1].apply(item[0], item[2]);
}
}, this.interval)
}
/**
* Stops flushing the queue immediately and leaves the rest of the queued items waiting.
*/
AutoFnQueue.prototype.interrupt = function() {
if (this.intervalId) clearInterval(this.intervalId);
this.intervalId = null;
this.flushing = false;
}
/**
* Interrupts and clears the queue.
*/
AutoFnQueue.prototype.clearQueue = function() {
this.interrupt();
this.items = [];
}
module.exports = {
"AutoFnQueue":AutoFnQueue
}

175
lib/clicommands.js Normal file
View file

@ -0,0 +1,175 @@
"use strict";
const C = require("cli-color");
const fs = require("fs");
const strings = require("./strings.js");
const api = require("./api.js");
const utils = require("./utils.js");
/*
Here are commands that will execute if used in the terminal, for example:
/say msg
These default commands are mostly used for debugging purposes.
"cmdname": function(bot, cmd [the cmd name used], message [the rest of the input after the command name]) {
action when used;
}
*/
var clicommands = {
"exit": function(bot) {
bot.kill("exit by user", 1000, 3);
},
"restart": function(bot) {
bot.kill("restart by user", 1000, 0);
},
"say": function(bot, cmd, message) {
bot.sendChatMsg(message, false, true, true);
},
"userinfo": function(bot, cmd, message) {
if (message.trim() === "") return;
let i = 0;
for (;i<bot.CHANNEL.users.length;i++) {
let user = bot.CHANNEL.users[i];
if (message.toLowerCase() === user.name.toLowerCase()) {
bot.logger.info(JSON.stringify(user));
}
}
},
"users": function(bot, cmd, message) {
//logic from CyTube: https://github.com/calzoneman/sync/blob/f081bc782adba074052884995b90bc77dcef3338/www/js/util.js#L417
function sortUserlist() {
let userlist = bot.CHANNEL.users;
userlist.sort(function(A,B) {
let nameA = A.name.toLowerCase(),
nameB = B.name.toLowerCase();
let afkA = A.meta.afk,
afkB = B.meta.afk;
if (afkA && !afkB) return 1;
if (!afkA && afkB) return -1;
let rankA = A.rank,
rankB = B.rank;
if (rankA < rankB) return 1;
if (rankA > rankB) return -1;
if (nameA > nameB) return 1;
if (nameA < nameB) return -1;
return 0;
});
}
sortUserlist();
let i = 0,
users = bot.CHANNEL.users,
out = [];
for (;i<users.length;i++) {
out.push(utils.colorUsername(bot, users[i]));
}
bot.logger.info("Registered users online: " + out.join(", "));
},
"videolist": function(bot, cmd, message) {
for (var i = 0; i < bot.CHANNEL.playlist.length; i++) {
bot.logger.info(JSON.stringify(bot.CHANNEL.playlist[i]));
}
},
"currentmedia": function(bot,cmd,message) {
var cm = bot.CHANNEL.currentMedia;
bot.logger.info(cm ? JSON.stringify(bot.CHANNEL.currentMedia) : "currentMedia appears to be empty.");
},
"readchanlog": function(bot, cmd, message) {
bot.readChanLog();
},
"setname": function(bot, cmd, message) {
if (bot.username === "" && !bot.guest) {
if (!utils.isValidUserName(message)) {
bot.logger.error("Invalid username. Must be 1-20 chars long and consist of -, _, or alphanumeric characters only.");
} else {
var fn = (()=>{
bot.socket.emit("login", {
name: message
});
});
bot.actionQueue.enqueue([this, fn, []]);
}
}
},
"showbumpstats": function(bot, cmd, message) {
let bs = bot.bumpStats;
bot.logger.info(JSON.stringify(bs));
},
"memory": function(bot, cmd, message) {
bot.logger.info(strings.format(bot, "MEMORY_USAGE", [(process.memoryUsage().heapUsed / 1024), "KB"]));
},
"subnet": function(bot, cmd, message) {
if (message.trim() === "") return;
let user = bot.getUser(message);
if (user && user.meta.ip) {
let matches = bot.matchSubnet(user.meta.ip, true);
bot.logger.info(matches.length > 0 ? matches.join(", ") : "No matches found.");
}
},
"setluck": function(bot,cmd,message) {
if (message.trim() === "") return;
let spl = message.split(" ");
if (spl.length < 2) return;
let user = bot.getUser(spl[0]);
let luck = parseInt(spl[1]);
if (user && !isNaN(luck)) {
bot.settings.lucky[user.name] = luck;
bot.logger.info("Set " + user.name + "'s luck to " + luck);
}
},
"testalllogs": function(bot,cmd,message) {
let logs = bot.logger;
for (var i in logs) {
logs[i]("Test message.");
}
},
"disablecommands": function(bot, cmd, message) {
bot.cfg.chat.disableAllCommands = true;
bot.logger.info("Disabled commands.");
},
"enablecommands": function(bot, cmd, message) {
bot.cfg.chat.disableAllCommands = false;
bot.logger.info("Enabled commands.");
},
"mute": function(bot, cmd, message) { //TODO: make global mute/unmute functions that the chatcommands will also use
if (!bot.getOpt("muted", false)) {
bot.logger.mod(strings.format(bot, "BOT_MUTED", ["CLI user"]));
bot.setOpt("muted", true);
} else {
bot.logger.warn("You're already muted.");
}
},
"unmute": function(bot, cmd, message) {
if (bot.getOpt("muted", false)) {
bot.logger.mod(strings.format(bot, "BOT_UNMUTED", ["CLI user"]));
bot.setOpt("muted", false);
} else {
bot.logger.warn("You're not muted.");
}
}
}
var aliases = {
kill: "exit"
}
module.exports = {
"exec":function(bot, input) {
if (bot.killed) return;
var split = input.split(" "),
cmd = split.splice(0,1)[0].substr(1);
if (aliases.hasOwnProperty(cmd) && !clicommands.hasOwnProperty(cmd)) {
cmd = aliases[cmd];
}
cmd = cmd.toLowerCase();
if (clicommands.hasOwnProperty(cmd)) {
clicommands[cmd](bot, cmd, split.join(" "));
} else {
bot.logger.warn(strings.format(bot, "UNKNOWN_CLI_COMMAND", [C.yellow("/" + cmd)]));
}
}
}

View file

@ -0,0 +1,79 @@
"use strict";
/*
Use this file to define custom commands, especially room-centric ones.
Try to avoid editing chatcommands.js so future updates won't erase your edits.
Rename this file to customchatcommands.js to use it, OR if the advanced
configuration setting "useChannelCustomCommands" is true, rename this to
customchatcommands-roomname.js instead.
You can also rename this to customchatcommands-(anything).js and edit your
configuration file accordingly. Within your config, refer to:
advanced.customCommandsToLoad
There is more information in the configuration file on how to set this up.
See chatcommands.js for more information on creating commands.
!!
This file may contain usage of emotes or other features
that you probably don't have in your room. These are just here as examples.
*/
const C = require("cli-color");
const utils = require("./utils.js");
const strings = require("./strings.js");
const api = require("./api.js");
const Command = require("./chatcommands.js").Command;
function getCommands(bot) {
var commands = {
"gdqschedule": new Command({
cmdName: "gdqschedule",
minRank: bot.RANKS.USER,
rankMatch: ">=",
userCooldown: 600000,
cmdCooldown: 300000,
isActive: false,
requiredChannelPerms: ["pollctl"],
allowRankChange: true,
canBeUsedInPM: true
}, function (cmd, user, message, opts) {
api.APIcall(bot, "gdq", null, null, function(status, data, ok) {
if (ok) {
let schedule = [], i = 0, now = Date.now();
let items = data.data.items;
for (;i < items.length && schedule.length < 6; i++) {
let time = (items[i].scheduled_t+items[i].length_t)*1000;
if (time > now) {
schedule.push(items[i]);
}
}
if (schedule.length > 0) {
let item = schedule[0];
let title = "now: " + item.data[0] + ", " + item.data[3] + " (runner: "+item.data[1]+", est: "+item.data[2]+")";
let options = [], i = 1;
for (;i < schedule.length; i++) {
let item = schedule[i];
options.push(new Date(item.scheduled_t*1000).toGMTString().split(" ")[4] + " UTC: " + item.data[0] + ", " + item.data[3] + " (runner: "+item.data[1]+", est: "+item.data[2]+")");
}
bot.openPoll({
title: title,
opts: options,
obscured: false
});
}
}
});
})
}
var aliases = {}
return {commands: commands, aliases: aliases}
}
module.exports = {
getCommands:getCommands
}

View file

@ -0,0 +1,145 @@
"use strict";
/*
Use this file to define custom commands, especially room-centric ones.
Try to avoid editing chatcommands.js so future updates won't erase your edits.
Rename this file to customchatcommands.js to use it, OR if the advanced
configuration setting "useChannelCustomCommands" is true, rename this to
customchatcommands-roomname.js instead.
You can also rename this to customchatcommands-(anything).js and edit your
configuration file accordingly. Within your config, refer to:
advanced.customCommandsToLoad
There is more information in the configuration file on how to set this up.
See chatcommands.js for more information on creating commands.
*/
const C = require("cli-color");
const utils = require("./utils.js");
const strings = require("./strings.js");
const api = require("./api.js");
const Command = require("./chatcommands.js").Command;
let toking = 0;//0 ready to start toke, 1 toking, 2 cooldown active
let tokers = [];
let cdown = 3;
let cdel = 120;
let ctime = cdel;
function getCommands(bot) {
var commands = {
"420blazeit": new Command({
cmdName: "420blazeit",
minRank: bot.RANKS.USER,
rankMatch: ">=",
userCooldown: 0,
cmdCooldown: 0,
isActive: true,
requiredChannelPerms: ["chat"],
allowRankChange: true,
canBeUsedInPM: false
}, function (cmd, user, message, opts) {
toke(user.name, bot);
})
}
var aliases = {
toak: "420blazeit",
666: "420blazeit",
420: "420blazeit",
toke: "420blazeit",
tokem: "420blazeit",
toek: "420blazeit",
hailsatan: "420blazeit",
cheers: "420blazeit",
toast: "420blazeit",
toastem: "420blazeit",
burn: "420blazeit",
burnem: "420blazeit",
lightem: "420blazeit",
dab: "420blazeit",
dabem: "420blazeit",
smoke: "420blazeit",
smokem: "420blazeit",
blaze: "420blazeit",
blazeit: "420blazeit",
blazem: "420blazeit",
}
return {commands: commands, aliases: aliases}
}
module.exports = {
getCommands:getCommands
}
function toke(name, bot){
switch (toking){
case 0://ready to start toke
bot.sendChatMsg("A group toke has been started by " + name + "! We'll be taking a toke in 60 seconds - join in by posting !toke");
cdown = 3;
toking = 1;
tokers.push(name);
setTimeout(countdown, 57000, bot);
break;
case 1://taking toke
if(tokers.includes(name)){
bot.sendPM(name, ("You're already taking part in this toke!"));
}else{
bot.sendChatMsg(name + " joined the toke! Post !toke to take part!");
tokers.push(name);
cdown = 3;
}
break;
case 2://cooldown
bot.sendPM(name, "Please wait " + ctime + " before starting a new group toke.");
break;
}
}
function countdown(bot){
toking = 1;//set toking mode
bot.sendChatMsg(cdown + "...");//send countdown msg
--cdown;//count down
if(cdown <= 0){//if cdown hits 0
setTimeout(endToke, 1000, bot);
}else{
setTimeout(countdown, 1000, bot);//call endtoke
}
}
function endToke(bot){
if(cdown != 0){
setTimeout(countdown, 1000, bot);
return;
}
if(tokers.length > 1){
bot.sendChatMsg("Take a toke " + tokers.toString() + "! " + tokers.length + " tokers!");
}else{
bot.sendChatMsg("Take a toke " + tokers.toString() + ". https://ourfore.st/img/femotes/onetoker.jpg");
}
tokers = [];
toking = 2;//reset toking mode
setTimeout(cooldown, 1000);
}
function cooldown(){
if(ctime > 0){
toking = 2;
--ctime;
setTimeout(cooldown, 1000);
}else{
toking = 0;
ctime = cdel;
}
}

View file

@ -0,0 +1,181 @@
"use strict";
/*
Use this file to define custom commands, especially room-centric ones.
Try to avoid editing chatcommands.js so future updates won't erase your edits.
Rename this file to customchatcommands.js to use it, OR if the advanced
configuration setting "useChannelCustomCommands" is true, rename this to
customchatcommands-roomname.js instead.
You can also rename this to customchatcommands-(anything).js and edit your
configuration file accordingly. Within your config, refer to:
advanced.customCommandsToLoad
There is more information in the configuration file on how to set this up.
See chatcommands.js for more information on creating commands.
!!
This file may contain usage of emotes or other features
that you probably don't have in your room. These are just here as examples.
*/
const C = require("cli-color");
const utils = require("./utils.js");
const strings = require("./strings.js");
const api = require("./api.js");
const Command = require("./chatcommands.js").Command;
function getCommands(bot) {
var commands = {
"fortune": new Command({
cmdName: "fortune",
minRank: bot.RANKS.USER,
rankMatch: ">=",
userCooldown: 20000,
cmdCooldown: 3000,
isActive: true,
requiredChannelPerms: ["chat"],
allowRankChange: true,
canBeUsedInPM: false
}, function (cmd, user, message, opts) {
var fortunes = [
["ssc:#F51C6A ","Reply hazy, try again",0],
["ssc:#FD4D32 ","Excellent Luck",14],
["ssc:#E7890C ","Good Luck",4],
["ssc:#BAC200 ","Average Luck",0],
["ssc:#7FEC11 ","Bad Luck",0],
["ssc:#43FD3B ","Good news will come to you by mail",1],
["ssc:#16F174 "," ´_ゝ`)フーン",0],
["ssc:#00CBB0 ","キタ━━━━━━(゚∀゚)━━━━━━ !!!!",2],
["ssc:#0893E1 ","You will meet a dark handsome stranger",1],
["ssc:#2A56FB ","Better not tell you now",0],
["ssc:#6023F8 ","Outlook good",4],
["ssc:#9D05DA ","Very Bad Luck",0],
["ssc:#D302A7 ","Godly Luck",29],
["ssc:#ff4f4f ","JUST",0]
];
let fortune = fortunes[Math.floor(Math.random() * fortunes.length)];
let settingChanged = false;
let userLucky = bot.settings.lucky[user.name];
if (fortune[2] > 0) {
bot.settings.lucky[user.name] = fortune[2];
settingChanged = true;
} else if (userLucky) {
delete bot.settings.lucky[user.name];
settingChanged = true;
}
if (settingChanged) bot.writeSettings();
if (!bot.cfg.chat.roomHasSSC) fortune[0] = "";
return bot.sendChatMsg(fortune[0] + "**" + user.name + "'s fortune: " + fortune[1] + "**");
}),
"saltpoll": new Command({
cmdName: "saltpoll",
minRank: bot.RANKS.MOD,
rankMatch: ">=",
userCooldown: 0,
cmdCooldown: 10000,
isActive: true,
requiredChannelPerms: ["pollctl"],
allowRankChange: true,
canBeUsedInPM: true
}, function (cmd, user, message, opts) {
api.APIcall(bot, "saltybet", null, null, function(status, data, ok) {
if (!ok) return bot.sendPM(user.name, "There was an error getting SaltyBet data.");
let poll = {
title:"PLACE YOUR BETS!",
opts: [],
obscured: false
},
emoteA = "",
emoteB = "";
let spl = message.split(" ");
if (spl[0]) emoteA = spl[0];
if (spl[1]) emoteB = spl[1];
let p1 = emoteA + " " + data.p1name,
p2 = emoteB + " " + data.p2name;
if (data.status === "open" || data.status === 2) {
poll.opts = [p1, p2];
} else if (data.status === "locked") {
p1 += " $" + data.p1total;
p2 += " $" + data.p2total;
poll.title = "BETTING CLOSED!";
poll.opts = [p1, p2];
}
if (poll.opts.length > 0) {
bot.openPoll(poll);
}
});
return true;
}),
"vidya": new Command({
cmdName: "vidya",
minRank: bot.RANKS.MOD,
rankMatch: ">=",
userCooldown: 0,
cmdCooldown: 2000,
isActive: true,
requiredChannelPerms: ["seeplaylist", "playlistmove"],
allowRankChange: true,
canBeUsedInPM: false
}, function (cmd, user, message, opts) {
let vidObj = null;
let spl = message.split(" ");
let playlist = bot.CHANNEL.playlist;
if (message.trim() !== "") {
vidObj = findLastMedia(spl[0]);
} else {
vidObj = findLastMedia(user.name);
}
if (vidObj) {
let TARGETUSER = vidObj.media.queueby;
if (bot.disallowed(TARGETUSER)) {
bot.sendPM(user.name, TARGETUSER + " is disallowed.");
return false;
}
let active = bot.getMediaIndex(bot.CHANNEL.currentUID);
if (!~active) {
bot.moveMedia(vidObj.media.uid, "prepend");
} else {
bot.moveMedia(vidObj.media.uid, playlist[active].uid);
}
bot.logger.mod(strings.format(bot, "BUMP_LOG", [
"VIDYA BUMP",
utils.formatLink(vidObj.media.media.id, vidObj.media.media.type, true),
TARGETUSER,
user.name,
(vidObj.index+1),
(~active ? active+1 : 1)
]));
if (bot.db && bot.cfg.db.useTables.users && bot.cfg.db.useTables.bump_stats) {
let column = "vidya_others";
if (TARGETUSER === user.name) column = "vidya_self";
bot.db.run("bumpCount", [user.name, column]);
}
return true;
}
return false;
})
}
let aliases = {};
function findLastMedia(name) {
name = name.toLowerCase();
let playlist = bot.CHANNEL.playlist;
let i = playlist.length-1;
for (;i >= 0;i--) {
if (playlist[i].queueby.toLowerCase() === name) {
return {media:playlist[i], index:i};
}
}
return null;
}
return {commands: commands, aliases: aliases}
}
module.exports = {
getCommands:getCommands
}

View file

@ -0,0 +1,52 @@
"use strict";
/*
Use this file to define custom commands, especially room-centric ones.
Try to avoid editing chatcommands.js so future updates won't erase your edits.
Rename this file to customchatcommands.js to use it, OR if the advanced
configuration setting "useChannelCustomCommands" is true, rename this to
customchatcommands-roomname.js instead.
You can also rename this to customchatcommands-(anything).js and edit your
configuration file accordingly. Within your config, refer to:
advanced.customCommandsToLoad
There is more information in the configuration file on how to set this up.
See chatcommands.js for more information on creating commands.
*/
const C = require("cli-color");
const utils = require("./utils.js");
const strings = require("./strings.js");
const api = require("./api.js");
const Command = require("./chatcommands.js").Command;
function getCommands(bot) {
var commands = {
"testcommand": new Command({
cmdName: "testcommand",
minRank: bot.RANKS.USER,
rankMatch: ">=",
userCooldown: 2000,
cmdCooldown: 2000,
isActive: true,
requiredChannelPerms: ["chat"],
allowRankChange: true,
canBeUsedInPM: false
}, function (cmd, user, message, opts) {
bot.sendChatMsg("Test command working!");
})
}
var aliases = {
testcmd: "testcommand"
}
return {commands: commands, aliases: aliases}
}
module.exports = {
getCommands:getCommands
}

462
lib/db.js Normal file
View file

@ -0,0 +1,462 @@
"use strict";
const pg = require("pg");
var pool = {};
module.exports["active"] = false;
module.exports["pool"] = pool;
module.exports["endPool"] = function(){};
module.exports["run"] = function(){return Promise.resolve(false)};
/*
Excuse the mess in here
someone please rewrite all this
*/
var initialized = false;
function init(bot) {
if (initialized) return;
initialized = true;
var active = bot.cfg.db.use,
schema = bot.CHANNEL.room;
module.exports["active"] = active;
if (active && schema.trim() !== "") {
try {
pool = new pg.Pool(bot.cfg.db.connectionInfo);
} catch (e) {
bot.logger.error(e.stack);
bot.logger.error(strings.format(bot, "DB_BAD_INFO"));
module.exports["active"] = false;
return;
}
module.exports["pool"] = pool;
module.exports["endPool"] = pool.end.bind(pool);
pool.query(`CREATE SCHEMA IF NOT EXISTS ${schema}`, (err, res) => {
genericHandler(err, res, function() {
//Define new tables here
if (!bot.cfg.db.useTables.users) return false;
pool.query(`
CREATE TABLE IF NOT EXISTS ${schema}.users
(
"uname" varchar(20) PRIMARY KEY,
"first_seen" timestamp DEFAULT NOW(),
"last_seen" timestamp NOT NULL DEFAULT NOW(),
"room_time" decimal(13,3) NOT NULL DEFAULT 0.000,
"afk_time" decimal(13,3) NOT NULL DEFAULT 0.000,
"joins" integer NOT NULL DEFAULT 1
);`, (err, res)=>{genericHandler(err,res)});
if (bot.cfg.db.useTables.emote_data)
pool.query(`
CREATE TABLE IF NOT EXISTS ${schema}.emote_data
(
"uname" varchar(20) REFERENCES ${schema}.users(uname),
"emote" varchar(320) NOT NULL,
"count" integer NOT NULL DEFAULT 1,
UNIQUE (uname, emote)
);`, (err, res)=>{genericHandler(err,res)});
if (bot.cfg.db.useTables.duel_stats)
pool.query(`
CREATE TABLE IF NOT EXISTS ${schema}.duel_stats
(
"uname" varchar(20) REFERENCES ${schema}.users(uname),
"wins" integer NOT NULL,
"losses" integer NOT NULL,
UNIQUE (uname)
);`, (err, res)=>{genericHandler(err,res)});
if (bot.cfg.db.useTables.chat)
pool.query(`
CREATE TABLE IF NOT EXISTS ${schema}.chat
(
"uname" varchar(20) REFERENCES ${schema}.users(uname),
"time" timestamp NOT NULL,
"msg" varchar(320) NOT NULL,
UNIQUE (uname, msg)
);`, (err, res)=>{genericHandler(err,res)});
if (bot.cfg.db.useTables.bump_stats)
pool.query(`
CREATE TABLE IF NOT EXISTS ${schema}.bump_stats
(
"uname" varchar(20) REFERENCES ${schema}.users(uname),
"others" integer NOT NULL DEFAULT 0,
"self" integer NOT NULL DEFAULT 0,
"vidya_self" integer NOT NULL DEFAULT 0,
"vidya_others" integer NOT NULL DEFAULT 0,
UNIQUE (uname)
);`, (err, res)=>{genericHandler(err,res)});
if (bot.cfg.db.useTables.saved_polls)
pool.query(`
CREATE TABLE IF NOT EXISTS ${schema}.saved_polls
(
"savedby" varchar(20) REFERENCES ${schema}.users(uname),
"poll_name" varchar(30) NOT NULL PRIMARY KEY,
"title" varchar(255) NOT NULL,
"obscured" boolean NOT NULL,
"options" text NOT NULL,
UNIQUE (title, obscured, options)
);`, (err, res)=>{genericHandler(err,res)});
/*if (bot.cfg.db.useTables.video_play_data)
pool.query(`
CREATE TABLE IF NOT EXISTS ${schema}.video_play_data
(
"uname" varchar(20) REFERENCES ${schema}.users(uname),
"mediaID" text NOT NULL,
"date_played" timestamp DEFAULT NOW() NOT NULL,
"skip_percent_needed" boolean,
"duration_percent" text,
UNIQUE (mediaID, date_played)
);`, (err, res)=>{genericHandler(err,res)});*/
})
})
/*
Define queries here.
Typically, you should call these like:
bot.db.run("queryName", [values], cb(res){})
Values is an array, and is used for parameterized values in most cases
cb is the callback carrying the database's response
*/
var queries = {
addNewChat: function(values, cb) {
//Check the required tables for each query
if (bot.cfg.db.useTables.users && bot.cfg.db.useTables.chat) {
//Return the Promise from runQuery
return runQuery(`INSERT INTO ${schema}.chat (uname, time, msg)
VALUES ($1, TO_TIMESTAMP($2), $3)
ON CONFLICT (uname, msg)
DO NOTHING;`, values, cb);
}
//Return false to reject the promise if one of the tables are not active
return false;
},
addNewUser: function(values, cb) {
if (bot.cfg.db.useTables.users) {
return runQuery(`INSERT INTO ${schema}.users (uname)
VALUES ($1)
ON CONFLICT (uname)
DO NOTHING
RETURNING joins;`, values, cb);
}
return false;
},
bumpCount: function(values, cb) {
if (bot.cfg.db.useTables.users && bot.cfg.db.useTables.bump_stats) {
return runQuery(`INSERT INTO ${schema}.bump_stats (uname, ${values[1]})
VALUES($1, 1)
ON CONFLICT (uname)
DO
UPDATE SET
${values[1]} = ${schema}.bump_stats.${values[1]} + 1`, [values[0]], cb);
}
return false;
},
cleanUnusedEmotes: function(values, cb) {
if (bot.cfg.db.useTables.users && bot.cfg.db.useTables.emote_data) {
return runQuery(`DELETE FROM ${schema}.emote_data
WHERE
emote = ANY($1)`, values, cb);
}
return false;
},
deleteUserChat: function(values, cb) {
if (bot.cfg.db.useTables.users && bot.cfg.db.useTables.chat) {
return runQuery(`DELETE FROM ${schema}.chat
WHERE
LOWER(uname)=LOWER($1)`, values, cb);
}
return false;
},
deleteUserEmotes: function(values, cb) {
if (bot.cfg.db.useTables.users && bot.cfg.db.useTables.emote_data) {
return runQuery(`DELETE FROM ${schema}.emote_data
WHERE
LOWER(uname)=LOWER($1)`, values, cb);
}
return false;
},
deletePoll: function(values, cb) {
if (bot.cfg.db.useTables.users && bot.cfg.db.useTables.saved_polls) {
return runQuery(`DELETE FROM ${schema}.saved_polls
WHERE
LOWER(savedby)=LOWER($1) AND poll_name=LOWER($2)`, values, cb);
}
return false;
},
insertDuelRecord: function(values, cb) {
if (bot.cfg.db.useTables.users && bot.cfg.db.useTables.duel_stats) {
return runQuery(`INSERT INTO ${schema}.duel_stats (uname, wins, losses)
VALUES ($1, 1, 0), ($2, 0, 1)
ON CONFLICT (uname)
DO
UPDATE SET
wins = excluded.wins + ${schema}.duel_stats.wins,
losses = excluded.losses + ${schema}.duel_stats.losses`, values, cb);
}
return false;
},
insertPoll: function(values, cb) {
if (bot.cfg.db.useTables.users && bot.cfg.db.useTables.saved_polls) {
return runQuery(`INSERT INTO ${schema}.saved_polls (savedby, poll_name, title, obscured, options)
VALUES ($1, LOWER($2), $3, $4, $5)
ON CONFLICT
DO NOTHING`, values, cb);
}
},
getPoll: function(values, cb) {
if (bot.cfg.db.useTables.users && bot.cfg.db.useTables.saved_polls) {
return runQuery(`SELECT * FROM ${schema}.saved_polls
WHERE
poll_name=LOWER($1)`, values, cb);
}
},
getDuelRecord: function(values, cb) {
if (bot.cfg.db.useTables.users && bot.cfg.db.useTables.duel_stats) {
return runQuery(`SELECT uname, wins, losses FROM ${schema}.duel_stats
WHERE
LOWER(uname)=LOWER($1);`, values, cb);
}
return false;
},
getRandomChat: function(values, cb) {
if (bot.cfg.db.useTables.users && bot.cfg.db.useTables.chat) {
return runQuery(`SELECT * FROM ${schema}.chat
WHERE
LOWER(uname)=LOWER($1)
OFFSET
floor(random()*(
SELECT count(*)
FROM ${schema}.chat
WHERE
LOWER(uname)=LOWER($1)))
LIMIT 1;`, values, cb);
}
return false;
},
getUserRoomTime: function(values, cb) {
if (bot.cfg.db.useTables.users) {
return runQuery(`SELECT first_seen, room_time, afk_time FROM ${schema}.users
WHERE
LOWER(uname)=LOWER($1);`, values, cb);
}
return false;
},
getUserEmoteCount: function(values, cb) {
if (bot.cfg.db.useTables.users && bot.cfg.db.useTables.emote_data) {
return runQuery(`SELECT uname, count FROM ${schema}.emote_data
WHERE
LOWER(uname)=LOWER($1) AND emote=$2`, values, cb);
}
return false;
},
getUserTotalEmoteCount: function(values, cb) {
if (bot.cfg.db.useTables.users && bot.cfg.db.useTables.emote_data) {
return runQuery(`SELECT uname, sum(count) FROM ${schema}.emote_data
WHERE
LOWER(uname)=LOWER($1)
GROUP BY uname`, values, cb);
}
return false;
},
getEmoteTotalCount: function(values, cb) {
if (bot.cfg.db.useTables.users && bot.cfg.db.useTables.emote_data) {
return runQuery(`SELECT sum(count) FROM ${schema}.emote_data
WHERE
emote=$1`, values, cb);
}
return false;
},
getTopFiveEmotes: function(values, cb) {
if (bot.cfg.db.useTables.users && bot.cfg.db.useTables.emote_data) {
var query = {
name: "topfiveemotes",
text:`SELECT emote, SUM(count) FROM ${schema}.emote_data
GROUP BY emote
ORDER BY sum
DESC
LIMIT 5`
};
return runQuery(query, values, cb);
}
return false;
},
getTopFiveEmoteUsers: function(values, cb) {
if (bot.cfg.db.useTables.users && bot.cfg.db.useTables.emote_data) {
return runQuery(`SELECT uname, sum(count) FROM ${schema}.emote_data
GROUP BY uname
ORDER BY sum
DESC
LIMIT 5;`, values, cb);
}
return false;
},
getStoredEmotes: function(values, cb) {
if (bot.cfg.db.useTables.users && bot.cfg.db.useTables.emote_data) {
return runQuery(`SELECT DISTINCT emote FROM ${schema}.emote_data`, values, cb);
}
return false;
},
getLastSeen: function(values, cb) {
if (bot.cfg.db.useTables.users) {
var query = {
text:`SELECT uname, last_seen FROM ${schema}.users
WHERE LOWER(uname)=LOWER($1)`
};
return runQuery(query, values, cb);
}
return false;
},
updateEmoteCounts: function(values, cb) {
if (bot.cfg.db.useTables.users && bot.cfg.db.useTables.emote_data) {
var query = {
text: `INSERT INTO ${schema}.emote_data (uname, emote, count)
VALUES ($1, $2, $3)
ON CONFLICT (uname, emote)
DO
UPDATE SET
count = $3 + ${schema}.emote_data.count`
}
for (var i = 0; i < values.length; i++) {
if (values[i].length === 3 && values[i][2] > 0) {
var _cb = i >= values.length - 1 ? cb : null;
runQuery(query, values[i], _cb);
}
}
}
return false;
},
updateUserRoomTime: function(values, cb) {
if (bot.cfg.db.useTables.users) {
runQuery(`UPDATE ${schema}.users
SET
room_time = $1 + ${schema}.users.room_time,
afk_time = $2 + ${schema}.users.afk_time,
last_seen = NOW()
WHERE
uname = $3`, values, cb);
}
return false;
},
updateUserAfkTime: function(values, cb) {
if (bot.cfg.db.useTables.users) {
values[0] /= 1000;
runQuery(`UPDATE ${schema}.users
SET
afk_time = $1 + ${schema}.users.afk_time
WHERE
uname = $2`, values, cb);
}
return false;
},
updateUserRoomTimeAll: function(values, cb) {
if (bot.cfg.db.useTables.users) {
//[username, roomtime, afktime]
var query = {
text: `UPDATE ${schema}.users
SET
room_time = $2 + ${schema}.users.room_time,
afk_time = $3 + ${schema}.users.afk_time,
last_seen = NOW()
WHERE
uname = $1`
}
for (var i = 0; i < values.length; i++) {
var _cb = i >= values.length - 1 ? cb : null;
runQuery(query, values[i], _cb);
}
}
return false;
},
updateUserLastSeen: function(values, cb) {
if (bot.cfg.db.useTables.users) {
return runQuery(`UPDATE ${schema}.users
SET
last_seen = NOW()
WHERE
uname = ANY ($1);`, [values], cb);
}
return false;
},
updateUserBlacklistState: function(values, cb) {
if (bot.cfg.db.useTables.users) {
return runQuery(`UPDATE ${schema}.users
SET
blacklisted = $2
WHERE
LOWER(uname) = LOWER($1);`, values, cb);
}
return false;
},
userJoin: function(values, cb) {
if (bot.cfg.db.useTables.users) {
return runQuery(`INSERT INTO ${schema}.users (uname)
VALUES ($1)
ON CONFLICT (uname)
DO UPDATE SET joins = ${schema}.users.joins + 1, last_seen = NOW()
RETURNING joins;`, values, cb);
}
return false;
}
}
module.exports.run = function(query, values, cb) {
if (!bot.cfg.db.use) return Promise.resolve(false);
return new Promise((resolve)=>{
if (queries.hasOwnProperty(query)) {
resolve(queries[query](values, cb));
} else resolve(false);
});
}
function genericHandler(err, res, cb) {
if (err) {
bot.logger.error(err.stack);
if (err.code === "ECONNREFUSED") {
disableDB(bot, "Connection to PostgreSQL refused, disabling database");
}
} else if (cb) cb();
}
function runQuery(query, values, cb) {
var released = false;
return pool.connect().then(client=>{
return client.query(query, values).then(res=>{
client.release();
released = true;
if (cb) cb(res);
return res;
})
.catch(e=>{
if (!released) client.release();
bot.logger.error(e.stack);
return null;
})
})
.catch(e=>{
bot.logger.error(e.stack);
return null;
})
}
}
}
async function disableDB(bot, errText) {
await module.exports.endPool();
module.exports["endPool"] = function(){};
module.exports["active"] = false;
module.exports["run"] = function(){return Promise.resolve(false)};
bot.cfg.db.use = false;
bot.logger.error(errText);
}
module.exports["init"] = init;

47
lib/discordbot.js Normal file
View file

@ -0,0 +1,47 @@
const C = require("cli-color");
const Discord = require("discord.js");
const utils = require("./utils.js");
const strings = require("./strings.js");
function DiscordBot(bot, token) {
this.client = new Discord.Client();
this.client.on("ready", ()=>{
bot.logger.log(C.greenBright(strings.format(bot, "DISCORD_READY")));
});
/*this.client.on("shardDisconnect", (ev, shardID)=>{
bot.logger.log(C.redBright("Discord Bot disconnected! [" + shardID + "]"));
});*/
/*this.client.on("shardReconnecting", (shardID)=>{
bot.logger.log(C.yellowBright("Discord Bot reconnecting... [" + shardID + "]"));
});*/
this.client.on("error", (err)=>{
bot.logger.error(C.red("Discord error: " + err));
});
this.client.login(token);
this.lastNowPlayingWasGreen = false;
this.createEmbed = function() { return new Discord.MessageEmbed()
.setFooter("https://"+bot.cfg.connection.hostname+"/r/" + bot.CHANNEL.room + " • " + utils.getUTCTimestamp() + " UTC", bot.cfg.discord.iconUrl);
}
}
DiscordBot.prototype.getChannel = function(id) {
return this.client.channels.cache.get(id);
}
module.exports = {
init: function(bot, token) {
if (token.trim() === "") {
bot.logger.error(strings.format(bot, "DISCORD_ERR_INIT", ["no token given"]));
return null;
}
return new DiscordBot(bot, token);
}
}

1148
lib/eventhandlers.js Normal file

File diff suppressed because it is too large Load diff

287
lib/strings.js Normal file
View file

@ -0,0 +1,287 @@
"use strict";
const C = require("cli-color");
//Define strings here, with the string ID as the key and the actual string as the value.
//Positional params are notated by %s# and begin at 0.
//Use the exported "format" function to retrieve these strings.
//e.g. "UNKNOWN_COMMAND" : "Unknown command %s0."
var strings = {
ACTIONQUEUE_INIT_INTERVAL:"Action queue interval set to %s0ms.",
ANAGRAM_BAD_LENGTH:"Anagram: Input must be between %s0-%s1 characters",
ANAGRAM_RESULT:"[%s0] => %s1",
API_ERROR:"APIcall error with %s0: %s1",
API_NOT_FOUND:"Tried to call an undefined API %s0",
API_NOT_OK:"API %s0 returned status code %s1: %s2",
API_PLAIN_RESPONSE:"[%s0] %s1",
API_TIMEOUT:"API request to \"%s0\" timed out.",
API_WR_RESPONSE: "[wolfram] %s0: %s1",
API_YT_COMMENTSDISABLED: "Comments are disabled on this video.",
API_YT_ERROR:"Error with YouTube API (%s0, status %s1): %s2",
API_YT_NOCOMMENTS: "No comments found on this video.",
AVATAR_BLACKLIST:"Your profile picture is hosted by a blacklisted domain. Please use a different host for it.",
BLACKLIST_FAIL:"%s0 is already blacklisted, or the input was invalid.",
BLACKLIST_MSG:"%s0 is a blacklisted video.",
BLACKLIST_REMOVE_FAIL:"%s0 was not found in the blacklist.",
BLACKLIST_REMOVE_SUCCESS:"%s0 successfully removed from the blacklist.",
BLACKLIST_SUCCESS:"%s0 successfully blacklisted.",
BLACKLIST_USER:"You are currently blacklisted from adding videos.",
BUMP_LOG:"%s0: %s1 added by %s2, bumped by %s3 (%s4 => %s5)",
BOT_MUTED:"%s0 muted the bot.",
BOT_MUTED_INIT:"Bot is starting muted!",
BOT_UNMUTED:"%s0 unmuted the bot.",
CALLBACK_INVALID:"%s0: callback is not a function!",
CHAT_BLANK_MESSAGE:"Tried to send a blank message",
CHAT_EC_USED: "<EC> %s0 has been used %s1 %s2.",
CHAT_EC_NOTUSED: "<EC> %s0 has not been used before.",
CHAT_EIGHTBALL: "[8ball: %s0] %s1",
CHAT_LS_INROOM: "%s0 is in the room right now.",
CHAT_LS_LASTSEEN: "%s0 was last seen at %s1.",
CHAT_LS_NOTSEEN: "%s0 has not been seen in the room yet.",
CHAT_QUOTE: "[%s0] <%s1> %s2",
CHAT_QUOTE_ME: "[%s0] <%s1 %s2>",
CHAT_ROOMTIME: "%s0: First seen at %s1. Total room time: %s2; active time: %s3 (%s4\%)",
CHAT_ROOMTIME_ONLYSEEN: "%s0: First seen at %s1. No room time recorded, most likely due to a reset.",
CHAT_UEC_USED: "<UserEC> %s0 has used %s1 %s2 %s3.",
CHAT_UEC_USEDTOTAL: "<UserEC> %s0 has used %s1 %s2.",
CHAT_UEC_NONEUSED: "<UserEC> %s0 has not used any emotes.",
CHAT_UEC_NOTUSED: "<UserEC> %s0 has not used %s1 before.",
CHAT_UPTIME: "Uptime: %s0",
CLI_INPUT: "[CLI] %s0",
CLI_NOT_ACCEPTING_INPUT:"Bot is not yet accepting CLI input, please wait",
CLI_ACCEPTING_INPUT:"Now accepting CLI input",
CHANNEL_NOT_REGISTERED:"This channel is not registered to a CyTube account. Some of CyTube's features within this channel will not be available. You can claim channels on CyTube via the Channels page of your account. If this was unexpected, make sure the correct channel is set within the bot's config.",
CHANNEL_OPTS_CHANGED:"Channel options changed: %s0",
CHANNEL_PERMS_UPDATED:"Channel permissions updated.",
CHANLOG_ERROR:"Error reading channel log.",
CHANLOG_READING:"Reading channel log...",
CHANLOG_WRITTEN:"Channel log written to chan.log.",
CHATFILTER_UPDATE:"Chat filter \"%s0\" has been added/updated.",
CHATFILTER_DELETE:"Chat filter \"%s0\" has been deleted.",
COLORNAME_NONAME:"utils.colorUsername called without a username",
COMMAND_ATTEMPT:"%s0 attempted chat command: %s1",
COMMAND_CHANPERM_FAIL:"Could not execute chat command \"%s0\" due to either an invalid permission name or insufficient permissions for \"%s1\". If this should have worked, double check the permission name.",
COMMAND_CREATING:"Creating chat commands...",
COMMAND_DISABLED:"Chat command \"%s0\" disabled.",
COMMAND_DISABLED_FAIL:"Tried to disable chat command \"%s0\" but it is already disabled.",
COMMAND_ENABLED:"Chat command \"%s0\" enabled.",
COMMAND_ENABLED_BROKEN:"Tried to enable chat command \"%s0\" but it is broken. Make sure it is correctly written.",
COMMAND_ENABLED_FAIL:"Tried to enable chat command \"%s0\" but it is already enabled.",
COMMAND_INACTIVE:"Chat command \"%s0\" is starting inactive.",
COMMAND_INVALID:"Chat command \"%s0\" has invalid properties and will not work.",
COMMAND_INVALID_UNEQUAL_ID:"Chat command \"%s0\"'s key name does not match its cmdName (make it lowercase!) and it will be considered broken!",
COMMAND_LISTENING:"Chat commands created, now listening for commands",
COMMAND_USED_BEFOREHANDLING:"Chat command \"%s0\" was used before handling commands",
COMMAND_USED_BROKEN:"Chat command \"%s0\" was used, but it is broken. Make sure the command has valid properties.",
COMMAND_USED_INACTIVE:"Chat command \"%s0\" was used, but it is inactive.",
COMMAND_USED_NOPM:"Chat command \"%s0\" was used in private, but that command cannot be used in PM.",
CONNECT_ERROR:"Unable to connect: %s0",
CONNECT_SUCCESS:C.greenBright("Successfully connected to %s0!"),
CONNECTING:C.yellowBright("Connecting..."),
CURRENTLY_PLAYING:C.cyan("Currently playing via ") + "%s0" + C.cyan(":") + " %s1 %s2 %s3 %s4",
CUSTOMCOMMANDS_ALIASES_NOT_OBJ:"Could not load custom command aliases: expected the aliases in an object.",
CUSTOMCOMMANDS_ALIASES_OVERWRITE:"Overwriting existing command alias with custom alias: %s0",
CUSTOMCOMMANDS_CMDS_NOT_OBJ:"Could not load custom commands: expected the commands in an object.",
CUSTOMCOMMANDS_LOAD_ERROR:"Error loading custom commands: %s0",
CUSTOMCOMMANDS_NOT_FOUND:"Could not load custom commands: customchatcommands.js not found. Rename customchatcommands-example.js in the lib folder to use the template. Ignore if not using custom commands.",
CUSTOMCOMMANDS_OVERWRITE:"Overwriting existing command with custom definition: %s0",
CY_ANNOUNCEMENT:"%s0 :: %s1 \u2014%s2",
DB_BAD_INFO:"Error creating database pool. Check your credential configuration.",
DB_EMOTES_CLEANED:"%s0: Emote records have been cleaned of unused emotes.",
DB_EMOTES_CLEANED_NONE:"%s0: No unused emotes were found in the database.",
DB_EMOTES_ERASED:"Emotes erased. You may also use \"%s0quotes off\" to exempt yourself from quotes and emote records. Keep in mind that this bot may still log chat among other room events.",
DB_QUOTES_ERASED:"Quotes erased. You may also use \"%s0quotes off\" to exempt yourself from quotes and emote records. Keep in mind that this bot may still log chat among other room events.",
DB_QUOTES_ERASED_OTHER:"%s0's quotes erased.",
DISCIPLINE_LOG:"%s0 used %s1 on %s2. Reason: %s3",
DISCONNECTED:C.redBright("Disconnected from server."),
DISCORD_ERR_INIT:"Error initiating Discord bot: %s0.",
DISCORD_EMBED_CLOSE_POLL_AUTHOR:":: poll closed ::",
DISCORD_EMBED_NEW_POLL_AUTHOR:":: poll opened ::",
DISCORD_EMBED_POLL_INPROGRESS_AUTHOR:":: poll in progress ::",
DISCORD_EMBED_POLL_TIMESTAMP:"Started by %s0 at %s1 UTC",
DISCORD_READY:"Discord Bot ready!",
DUEL_BEGIN:"%s0: %s1 challenged you to a duel! Type \`%s2%s3\`, or \`%s2%s4\` like a pussy...",
DUEL_DECLINE:"%s0 declined %s1's duel! What a bitch! %s2",
DUEL_EXPIRED:"%s0's duel request has expired due to %s1 being a little bitch.",
DUEL_PM_INDUEL:"That user is already in a duel.",
DUEL_PM_CALLERWAITING:"You're waiting for %s0 to respond to your duel request!",
DUEL_PM_TARGETWAITING:"%s0 is waiting for you to respond to their duel request!",
DUEL_RECORD:"%s0's duel record: %s1W-%s2L, win rate: %s3",
DUEL_RESULT_LOSS:"%s1 WINS against %s0! [%s3 vs %s2] %s4",
DUEL_RESULT_WIN:"%s0 WINS against %s1! [%s2 vs %s3] %s4",
DUEL_USER_LEFT:"%s0 left the room; pending duel with %s1 has ended.",
EMOTE_REMOVE:"Emote \"%s0\" removed.",
EMOTE_RENAME:"Emote \"%s0\" renamed to \"%s1\".",
EMOTE_REJECTED:"%s0: Rejecting invalid emote: %s1",
EMOTE_UPDATE:"Emote \"%s0\" added/updated.",
EXIT:"Bot stopped, exiting in %s0. Reason: %s1",
FILE_READ_ERROR:"Error reading from %s0: %s1",
FILE_WRITE_ERROR:"Error writing to %s0: %s1",
INIT:"-- Initializing %s0 v%s1 --",
INVALID_USERNAME:"That username is invalid.",
JOINING_ROOM:"Joining %s0",
KICKED:"You have been kicked.%s0",
KILL_GENERIC_DISCONNECT:"disconnected",
KILL_WRONG_PWD:"wrong password",
LEADER_GIVEN:"Leader given to %s0",
LEADER_NOPERM:"The bot does not have permission to make itself a leader. Make it one before using this command.",
LEADER_REMOVED:"Leader removed from %s0",
LOGIN_SUCCESS:"Successfully logged in as: %s0",
MEDIA_ADD_BOTTOM:"%s0 added %s1 to the bottom of the playlist",
MEDIA_ADD_POS:"%s0 added %s1 to #%s2",
MEDIA_ADD_TOP:"%s0 added %s1 to the top of the playlist",
MEDIA_MOVE_AFTER:"%s0 moved after %s1 (#%s2 -> #%s3)",
MEDIA_MOVE_TOP:"%s0 moved to the top of the playlist (#%s1 -> #%s2)",
MEMORY_USAGE:"Memory usage: %s0%s1",
MOTD_CHANGED:"The channel's MOTD has been changed.",
NEW_POLL:"%s0 opened a poll at %s1: %s2 %s3",
NEW_USER_CHAT:"Your account is too new to chat in this channel. Please wait a while and try again.",
NEW_USER_CHAT_LINK:"Your account is too new to post links in this channel. Please wait a while and try again.",
NEW_USER_JOIN:"New user: %s0",
NEW_USERS:"New users: %s0",
NO_ABORT:"Type /exit instead of using CTRL-C to exit the bot.",
NO_FLOOD:C.redBright("%s0: %s1"),
NO_USERNAME:"NO_USERNAME",
NO_VIDEO:"No video is playing.",
NOW_PLAYING:C.yellowBright("Now playing via ") + "%s0" + C.yellowBright(":") + " %s1 %s2 %s3",
PARTITION_CHANGE:"Reconnecting due to a partition change...",
PERMISSION_INSUFFICIENT:"Insufficient permission (rank %s2) for %s0; need rank %s1.",
PERMISSION_NOT_FOUND:"Tried to check permission %s0 but it wasn't found!",
PLAYLIST_EMPTY:"The playlist is empty.",
PLAYLIST_IS_LOCKED:"The playlist is currently " + C.redBright("locked") + ".",
PLAYLIST_INVALID_POSITION:"Invalid playlist position.",
PLAYLIST_LOCKED:"Playlist locked.",
PLAYLIST_LOW:"Playlist time is running low. Add videos!",
PLAYLIST_RECEIVED:"Playlist data received.",
PLAYLIST_IS_UNLOCKED:"The playlist is currently " + C.greenBright("unlocked") + ".",
PLAYLIST_UNLOCKED:"Playlist unlocked.",
PLAYLIST_VIDEONOTFOUND:"Video not found.",
PM_RECV:"Received PM from %s0: %s1",
PM_SENT:"Sent PM to %s0: %s1",
POLL_CLOSED:"%s0's poll closed: %s1 %s2",
POLL_CLOSED_RESULT:"\"%s0\": %s1 had the most votes with %s2 vote(s) (%s3)",
POLL_CLOSED_TIE:"\"%s0\": Some options tied with %s1 vote(s) each (%s2)",
POLL_CLOSED_CMD:"%s0 ended the poll via chat command.",
PWD_ACCEPTED:"Room password accepted!",
PWD_REQUIRED:"Room %s0 requires a password, attempting to join...",
QUEUE_FAIL:"Queue failure: %s0",
QUEUE_WARN:"Queue warning: %s0",
QUOTE_NO_ARG_PM:"Currently %s0 from quotes and chat/emote storage (does not include channel logs). Type \"%s1%s2 on\" or \"%s1%s2 off\" to control this, or \"%s1clearquotes\" or \"%s1clearemotecount\" to erase your stored messages or emote records respectively (except those in logs).",
QUOTE_OFF_PM:"You're now exempt from %s0quote and any further messages and emotes will not be stored (except normal channel logging). Type \"%s0quotes on\" to enable this again, or \"%s0clearquotes\" or \"%s0clearemotecount\" to erase any of your stored messages or emote records respectively (again, does not erase logs).",
QUOTE_ON_PM:"Your chat messages and emotes may now be stored and then retrieved when users use %s0quote and emote count commands. Type \"%s0quotes off\" to exempt yourself from this.",
RANK_SET:"Rank set to %s0.",
RANK_SET_USER:"Rank for %s0 set to %s1",
SAVEPOLL_ERR_NOACTIVE:"There must be a poll active with at least a title or some options.",
SAVEPOLL_ERR_STARTSWITHTIME:"Poll name cannot begin with \"time:\", because loadpoll uses this for the timer.",
SAVEPOLL_ERR_NAMELENGTH:"Poll name must be %s0-%s1 characters.",
SAVEPOLL_ERR_OPTLENGTH:"Poll must have %s0 or less options.",
SAVEPOLL_ERR_NOTUNIQUE:"Could not save the poll. There may be a poll with the same name, or the same title and options.",
SAVEPOLL_SUCCESS:"Poll saved as: %s0",
SEEK_TOOFAR:"Can't seek past the length of the video.",
SELFPURGE_NOARG:"Did you mean \"%s0selfremove\"? If not, try \"%s0selfpurge\" again with nothing after it.",
SELFPURGE_SEMISUCCESS:"Your videos have been purged. However, the current video was not deleted.",
SELFPURGE_SUCCESS:"Your videos have been purged.",
SELFREMOVE_ERR_ACTIVE:"You may not remove your video if it is playing.",
SELFREMOVE_ERR_NOTYOURS:"Did not remove %s0: You may only remove videos that you have added.",
SELFREMOVE_SUCCESS:"Removed %s0",
SELFREMOVE_USAGE:"%s0%s1 <video position number|first|last> - removes the video at the given position (or first or last video found if specified) if added by you",
SERVER_BAD_HOSTNAME:"Server found, but it was not on %s0. Stopping.",
SERVER_INSECURE:"config.connection.secureServer is false! Bot will use an INSECURE and UNENCRYPTED server!",
SERVER_REQUEST_ERROR:"Failure requesting socket configuration: %s0",
SERVER_ROOM_NOT_FOUND:"getSocketConfig: unable to find a server for room %s0",
SETTINGS_PROPERTY_MISSING:"Loaded settings file does not have property %s0, updating",
SETTINGS_READ:"Reading persistent settings",
SETTINGS_WRITE:"Writing persistent settings",
SHUFFLE_ERR:"The shuffle command can only be used without any other text in the message. Did you mean \"%s0shuffleuser\"?",
SKIPRATE_CHANGE:"Skip ratio changed from %s0\% to %s1\%",
SOCKET_CONN_ERR:"Unable to connect to the socket server.",
SPAM_FILTERED:"Spam filtered.",
STOPALLTIMERS_FAIL:"stopAllTimers cannot be called if the bot has not been killed, unless forcefully done so!",
SUBNET_MATCH:"%s0's subnet matches banned IPs: %s1",
TARGETUSER_EXEMPT: "That user does not allow chat record retrieval.",
TIMEBAN_NONE:"A timeban was not found for %s0.",
TIMEBAN_SOON:"%s0 is timebanned but will be automatically unbanned within a minute or so.",
TIMEBAN_TIME:"%s0 has %s1 left on their ban.",
TIMECODE_BADFORMAT:"Invalid time. Must be in [H:]M:S format.",
TRIGGER_INVALID:"Invalid trigger found. Reverted trigger to \"%s0\"",
UNABLE_TO_LOGIN:"Unable to login. Check your credentials. Error: %s0",
UNBAN_FAIL_TIMEBANNED:"%s0 is timebanned. Use %s1untimeban instead, or %s1gettimeban to see the ban length.",
UNBANNED:"%s0 unbanned (id: %s1, ip: %s2)",
UNKNOWN_CHAT_COMMAND:"Unknown chat command: %s0",
UNKNOWN_CLI_COMMAND:"Unknown console command \"%s0\".",
USER_ALLOWED:"Allowed %s0",
USER_ALLOWED_FAIL:"Tried to allow %s0 but they are not disallowed.",
USER_DISALLOWED:"Disallowed %s0",
USER_DISALLOWED_FAIL:"Tried to disallow %s0 but they are already disallowed.",
USER_JOINED_ROOM:C.green("+ ") + "%s0" + C.green(" joined the room."),
USER_JOINED_ROOM_ALIASES:C.green("+ ") + "%s0" + C.green(" joined the room. ") + C.blackBright("(aliases: %s1)"),
USER_LEFT_ROOM:C.red("- ") + "%s0" + C.red(" left the room."),
USER_PURGED:"Purged %s0's videos.",
WRONG_PWD:"Wrong password for room %s0! If not done already, please set the room password within the config.",
COMMAND_RANK_CHANGED:"Rank for chat command %s0 changed from %s1=>%s2 by %s3.",
COMMAND_RANKMATCH_CHANGED:"Rank match for chat command %s0 changed from %s1 to %s2 by %s3.",
COMMAND_REQUIRED_RANK:"Required rank for %s0: %s2%s1",
COMMAND_RUNTIME_ERROR:"That command encountered a runtime error. Tell the bot maintainer.",
COOLDOWN_C_ACTIVE:"Command cooldown for %s0 is still active. %s1 seconds remaining.",
COOLDOWN_U_ACTIVE:"User cooldown for %s0 is still active. %s1 seconds remaining.",
DBG_CMD_CHECKOVERRIDE:"Checking for chat command property overrides",
DBG_CMD_FOUNDCDOVERRIDE:"Found user cooldown override for chat command %s0 (%s1 => %s2)",
DBG_CMD_FOUNDGCDOVERRIDE:"Found global cooldown override for chat command %s0 (%s1 => %s2)",
DBG_CMD_FOUNDRANKOVERRIDE:"Found rank override for chat command %s0 (%s1 => %s2)",
DBG_CMD_FOUNDRANKOVERRIDE_NOTALLOWED:"Found rank override for chat command %s0 but it does not allow rank changes. Skipping.",
DBG_CMD_FOUNDRANKMATCHOVERRIDE:"Found rankmatch override for chat command %s0 (%s1 to %s2)",
DBG_CMD_FOUNDRANKMATCHOVERRIDE_NOTALLOWED:"Found rankmatch override for chat command %s0 but it does not allow rankmatch changes. Skipping.",
DBG_CMD_FOUNDSTATEOVERRIDE:"Found active state override for chat command %s0 (%s1 => %s2)",
DBG_CMD_SETCDPROP:"Setting %s0 property in cooldown obj",
DBG_FOUND_SOCKET:"Found socket config info, connecting to %s0",
DBG_REMOVEUID:"Removed uid:%s0",
DBG_SETCURRENT:"setCurrent called with uid:%s0",
DBG_SETTING_HANDLERS:"Setting socket event handlers",
RECV_BANLIST:"Ban list received.",
RECV_RANKS:"Rank list received.",
RECV_USERLIST:"User list received."
}
module.exports = {
//format: Takes a stringID (the string key) and an array of strings as parameters.
//params may be left null if the requested string does not take parameters.
"format":function(bot, stringID, params) {
if (bot && bot.pendingLanguageChange) {
try {
if (bot.pendingLanguageChange.length < 2 || bot.pendingLanguageChange.length > 3) {
if (bot.pendingLanguageChange === "reset") {
let file = require("./strings.js");
strings = file._strings;
} else
throw new Error("Language code must be 2-3 chars!");
} else {
let file = require("./strings-" + bot.pendingLanguageChange + ".js");
strings = file._strings;
}
} catch (e) {
if (e.code === "MODULE_NOT_FOUND"){}
else
bot.logger.error(e.stack);
}
bot.pendingLanguageChange = null;
}
if (strings.hasOwnProperty(stringID)) {
if (params === null || params === undefined) return strings[stringID];
return strings[stringID].replace(/\%s(\d+)/g, function(match, capture) {
var num = parseInt(capture);
if (!isNaN(num) && num < params.length)
return params[num];
else
return match;
});
} else {
bot.logger.error("strings.format: Requested stringID " + stringID + " but it is not defined!");
return false;
}
},
"_strings":strings
}

792
lib/utils.js Normal file
View file

@ -0,0 +1,792 @@
"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;
}
}