/**
* StreamRoller Copyright 2023 "SilenusTA https://www.twitch.tv/olddepressedgamer"
*
* StreamRoller is an all in one streaming solution designed to give a single
* 'second monitor' control page and allow easy integration for configuring
* content (ie. tweets linked to chat, overlays triggered by messages, hue lights
* controlled by donations etc)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/**
* @extension Quizbot
* Runs a quizbot from a list of files to entertain your viewers.
*/
// ============================================================================
// IMPORTS/VARIABLES
// ============================================================================
import * as fs from "fs";
import { dirname } from 'path';
import { fileURLToPath } from 'url';
import * as logger from "../../backend/data_center/modules/logger.js";
import sr_api from "../../backend/data_center/public/streamroller-message-api.cjs";
const __dirname = dirname(fileURLToPath(import.meta.url));
const localConfig = {
SYSTEM_LOGGING_TAG: "[EXTENSION]",
DataCenterSocket: null,
quizQuestions: [],
currentQuestionAnswer: 0,
heartBeatTimeout: 5000,
heartBeatHandle: null,
quizbotTimerHandle: undefined
};
const default_serverConfig = {
__version__: "0.2",
extensionname: "quizbot",
channel: "QUIZBOT_CHANNEL",
// setting variables
quizbot_enabled: "off",
quizbot_restart: "on",
quizbot_showanswerinconsole: "off",
quizbot_restore_defaults: "off",
quizbot_filename: "questions.txt",
quizbot_duration: "3600000",
};
let serverConfig = structuredClone(default_serverConfig)
const triggersandactions =
{
extensionname: serverConfig.extensionname,
description: "Quiz bot, ask chat a question",
version: "0.2",
channel: serverConfig.channel,
triggers:
[
{
name: "quizbotQuizStarted",
displaytitle: "QuizStarted",
description: "Quiz was started, restarted or a new question was asked",
messagetype: "trigger_QuizbotQuizStarted",
parameters: { question: "" }
},
{
name: "quizbotQuizStopped",
displaytitle: "QuizStopped",
description: "Quiz was stopped",
messagetype: "trigger_QuizbotQuizStopped",
parameters: { question: "", answer: "" }
},
{
name: "quizbotQuizTimeout",
displaytitle: "QuizTimeout",
description: "Quiz Question timedout",
messagetype: "trigger_QuizbotQuizTimeout",
parameters: { question: "", answer: "" }
},
{
name: "quizbotIncorrectAnswer",
displaytitle: "IncorrectAnswer",
description: "Someone provided an incorrec answer",
messagetype: "trigger_QuizbotIncorrectAnswer",
parameters: { user: "", question: "", answer: "" }
},
{
name: "quizbotCorrectAnswer",
displaytitle: "CorrectAnswer",
description: "Someone answered the quiz question correctly",
messagetype: "trigger_QuizbotCorrectAnswer",
parameters: { user: "", question: "", answer: "" }
},
],
actions:
[
{
name: "quizbotStartQuiz",
displaytitle: "Start-Restart the quiz",
description: "Start, restart or skip current question",
messagetype: "action_QuizbotStartQuiz",
parameters: {}
},
{
name: "quizbotStopQuiz",
displaytitle: "Stop the quiz",
description: "Stop the Quiz",
messagetype: "action_QuizbotStopQuiz",
parameters: {}
},
{
name: "quizbotCheckAnswer",
displaytitle: "Check an answer",
description: "Check if answer is correct (including chat tag, ie !answer",
messagetype: "action_QuizbotCheckAnswer",
parameters: { user: "", answer: "" }
}
],
}
// ============================================================================
// FUNCTION: initialise
// ============================================================================
/**
* Starts the extension using the given data.
* @param {object:Express} app
* @param {string} host
* @param {number} port
* @param {number} heartbeat
*/
function initialise (app, host, port, heartbeat)
{
if (typeof (heartbeat) != "undefined")
localConfig.heartBeatTimeout = heartbeat;
try
{
localConfig.DataCenterSocket = sr_api.setupConnection(onDataCenterMessage, onDataCenterConnect,
onDataCenterDisconnect, host, port);
} catch (err)
{
logger.err(localConfig.SYSTEM_LOGGING_TAG + serverConfig.extensionname + ".initialise", "localConfig.DataCenterSocket connection failed:", err.message);
}
}
// ============================================================================
// FUNCTION: onDataCenterDisconnect
// ============================================================================
/**
* CAlled on socket disconnect
* @param {string} reason
*/
function onDataCenterDisconnect (reason)
{
logger.log(localConfig.SYSTEM_LOGGING_TAG + serverConfig.extensionname + ".onDataCenterDisconnect", reason);
}
// ============================================================================
// FUNCTION: onDataCenterConnect
// ============================================================================
/**
* Called on socket connect
* @param {object} socket
*/
function onDataCenterConnect (socket)
{
logger.log(localConfig.SYSTEM_LOGGING_TAG + serverConfig.extensionname + ".onDataCenterConnect", "Creating our channel");
sr_api.sendMessage(localConfig.DataCenterSocket,
sr_api.ServerPacket("RequestConfig", serverConfig.extensionname));
sr_api.sendMessage(localConfig.DataCenterSocket,
sr_api.ServerPacket("CreateChannel", serverConfig.extensionname, serverConfig.channel)
);
// clear the previous timeout if we have one
clearTimeout(localConfig.heartBeatHandle);
// start our heatbeat timer
localConfig.heartBeatHandle = setTimeout(heartBeatCallback, localConfig.heartBeatTimeout);
}
// ============================================================================
// FUNCTION: onDataCenterMessage
// ============================================================================
/**
* Called when a message is received
* @param {object} server_packet
*/
function onDataCenterMessage (server_packet)
{
if (server_packet.type === "ConfigFile")
{
if (server_packet.data != "" && server_packet.to === serverConfig.extensionname)
{
if (server_packet.data.__version__ != default_serverConfig.__version__)
{
serverConfig = structuredClone(default_serverConfig);
console.log("\x1b[31m" + serverConfig.extensionname + " ConfigFile Updated", "The config file has been Restored. Your settings may have changed" + "\x1b[0m");
}
else
serverConfig = structuredClone(server_packet.data);
SaveConfigToServer();
}
readQuizFile()
}
else if (server_packet.type === "ExtensionMessage")
{
let extension_packet = server_packet.data;
if (extension_packet.type === "RequestSettingsWidgetSmallCode")
SendSettingsWidgetSmall(extension_packet.from);
else if (extension_packet.type === "SettingsWidgetSmallData")
{
if (extension_packet.to === serverConfig.extensionname)
{
if (extension_packet.data.quizbot_restore_defaults == "on")
{
serverConfig = structuredClone(default_serverConfig);
console.log("\x1b[31m" + serverConfig.extensionname + " ConfigFile Updated.", "The config file has been Restored. Your settings may have changed" + "\x1b[0m");
}
else
{
let quizstarted = false
let quizstopped = false
// check if we have toggled the enabled flag
if (serverConfig.quizbot_enabled == "on" && extension_packet.data.quizbot_enabled != "on")
quizstopped = true;
else if (serverConfig.quizbot_enabled == "off" && extension_packet.data.quizbot_enabled == "on")
quizstarted = true;
serverConfig.quizbot_enabled = "off";
serverConfig.quizbot_restart = "off";
serverConfig.quizbot_showanswerinconsole = "off";
serverConfig.quizbot_restore_defaults = "off";
for (const [key, value] of Object.entries(extension_packet.data))
{
if (key == "quizbot_duration")
{
// if we have an updated timeout set it and restart the quiz
if (value != serverConfig[key] / 1000)
{
quizstarted = true;
if (value > 1)
serverConfig[key] = value * 1000;
else
serverConfig[key] = default_serverConfig.quizbot_duration
}
}
else
serverConfig[key] = value;
}
// check if we want to restart the current quiz
if (serverConfig.quizbot_enabled == "on" && serverConfig.quizbot_restart == "on")
quizstarted = true;
// reset the restart flag
serverConfig.quizbot_restart = "off";
if (quizstopped)
stopQuiz()
if (quizstarted)
startQuiz()
}
SaveConfigToServer();
SendSettingsWidgetSmall("");
}
}
else if (extension_packet.type === "SendTriggerAndActions")
{
sr_api.sendMessage(localConfig.DataCenterSocket,
sr_api.ServerPacket("ExtensionMessage",
serverConfig.extensionname,
sr_api.ExtensionPacket(
"TriggerAndActions",
serverConfig.extensionname,
triggersandactions,
"",
server_packet.from
),
"",
server_packet.from
)
)
}
else if (extension_packet.type === "action_QuizbotStartQuiz")
{
if (extension_packet.to === serverConfig.extensionname)
{
serverConfig.quizbot_enabled = "on"
startQuiz();
}
}
else if (extension_packet.type === "action_QuizbotStopQuiz")
{
if (extension_packet.to === serverConfig.extensionname)
{
stopQuiz();
}
}
else if (extension_packet.type === "action_QuizbotCheckAnswer")
{
if (extension_packet.to === serverConfig.extensionname)
checkAnswer(extension_packet.data);
}
else
logger.log(localConfig.SYSTEM_LOGGING_TAG + serverConfig.extensionname + ".onDataCenterMessage", "received unhandled ExtensionMessage ", server_packet);
}
else if (server_packet.type === "UnknownChannel")
{
logger.warn(localConfig.SYSTEM_LOGGING_TAG + serverConfig.extensionname + ".onDataCenterMessage", "Channel " + server_packet.data + " doesn't exist, scheduling rejoin");
}
else if (server_packet.type === "ChannelData")
{
let extension_packet = server_packet.data;
if (extension_packet.type === "HeartBeat")
{
//Just ignore messages we know we don't want to handle
}
else
{
logger.log(localConfig.SYSTEM_LOGGING_TAG + serverConfig.extensionname + ".onDataCenterMessage", "received message from unhandled channel ", server_packet.dest_channel);
}
}
else if (server_packet.type === "InvalidMessage")
{
logger.err(localConfig.SYSTEM_LOGGING_TAG + serverConfig.extensionname + ".onDataCenterMessage",
"InvalidMessage ", server_packet.data.error, server_packet);
}
else if (server_packet.type === "LoggingLevel")
{
logger.setLoggingLevel(server_packet.data)
}
else if (server_packet.type === "ChannelJoined"
|| server_packet.type === "ChannelCreated"
|| server_packet.type === "ChannelLeft"
)
{
// just a blank handler for items we are not using to avoid message from the catchall
}
// ------------------------------------------------ unknown message type received -----------------------------------------------
else
logger.warn(localConfig.SYSTEM_LOGGING_TAG + serverConfig.extensionname +
".onDataCenterMessage", "Unhandled message type", server_packet.type);
}
// ===========================================================================
// FUNCTION: SendSettingsWidgetSmall
// ===========================================================================
/**
* Sends out out Small settings widget
* @param {string} tochannel
*/
function SendSettingsWidgetSmall (tochannel)
{
fs.readFile(__dirname + '/quizbotsettingswidgetsmall.html', function (err, filedata)
{
if (err)
logger.err(localConfig.SYSTEM_LOGGING_TAG + serverConfig.extensionname +
".SendSettingsWidgetSmall", "failed to load modal", err);
else
{
let modalstring = filedata.toString();
for (const [key, value] of Object.entries(serverConfig))
{
if (value === "on")
modalstring = modalstring.replace(key + "checked", "checked");
else if (key == "quizbot_duration")
modalstring = modalstring.replace(key + "text", value / 1000);
else if (typeof (value) == "string")
modalstring = modalstring.replace(key + "text", value);
}
sr_api.sendMessage(localConfig.DataCenterSocket,
sr_api.ServerPacket(
"ExtensionMessage",
serverConfig.extensionname,
sr_api.ExtensionPacket(
"SettingsWidgetSmallCode",
serverConfig.extensionname,
modalstring,
"",
tochannel,
serverConfig.channel
),
"",
tochannel
))
}
});
}
// ============================================================================
// FUNCTION: SaveConfigToServer
// ============================================================================
/**
* Saves our config to the server
*/
function SaveConfigToServer ()
{
sr_api.sendMessage(localConfig.DataCenterSocket, sr_api.ServerPacket
("SaveConfig",
serverConfig.extensionname,
serverConfig))
}
// ============================================================================
// FUNCTION: readQuizFile
// ============================================================================
/**
* Reads the question/answer file for the quiz
*/
function readQuizFile ()
{
try
{
fs.readFile(__dirname + "/" + serverConfig.quizbot_filename, (err, filedata) =>
{
if (err)
logger.err(localConfig.SYSTEM_LOGGING_TAG + serverConfig.extensionname +
".readQuizFile", "failed read file", err.message);
else
{
let str = filedata.toString()
if (str.indexOf("\r") > -1)
localConfig.quizQuestions = str.split("\r\n")
else
localConfig.quizQuestions = str.split("\n")
// if we are set to run then start the quiz
if (serverConfig.quizbot_enabled == "on")
startQuiz()
}
})
}
catch (err)
{
logger.err(localConfig.SYSTEM_LOGGING_TAG + serverConfig.extensionname + ".readQuizFile", "Failed to read quiz file", err.message);
}
}
// ============================================================================
// FUNCTION: sendQuizStarted
// ============================================================================
/**
* Sends the trigger_QuizbotQuizStarted trigger on quiz start
*/
function sendQuizStarted ()
{
let QA = localConfig.quizQuestions[localConfig.currentQuestionAnswer].split("##")
let data = findtriggerByMessageType("trigger_QuizbotQuizStarted")
let answer = QA[1].trim();
answer = answer.replaceAll(/[!-~]/ig, "#")
data.parameters.question = QA[0].trim() + ": !answer " + answer
sr_api.sendMessage(localConfig.DataCenterSocket,
sr_api.ServerPacket("ChannelData",
serverConfig.extensionname,
sr_api.ExtensionPacket(
"trigger_QuizbotQuizStarted",
serverConfig.extensionname,
data,
serverConfig.channel
),
serverConfig.channel)
)
}
// ============================================================================
// FUNCTION: sendFirstLine
// ============================================================================
/**
* Checks an answer to the current question. Sends the triggers trigger_QuizbotIncorrectAnswer
* or trigger_QuizbotCorrectAnswer depending on the result
* @param {string} userAnswer
*/
function checkAnswer (userAnswer)
{
let currentAnswer = localConfig.quizQuestions[localConfig.currentQuestionAnswer].split("##")[1].trim()
let answer = userAnswer.answer.replace("!answer ", "").trim()
let data = {}
let messagetype = ""
if (currentAnswer.toLowerCase() == answer.toLowerCase())
messagetype = "trigger_QuizbotCorrectAnswer";
else
messagetype = "trigger_QuizbotIncorrectAnswer";
data = findtriggerByMessageType(messagetype);
data.parameters.question = localConfig.quizQuestions[localConfig.currentQuestionAnswer].split("##")[0].trim();
data.parameters.answer = answer;
data.parameters.user = userAnswer.user;
sr_api.sendMessage(localConfig.DataCenterSocket,
sr_api.ServerPacket("ChannelData",
serverConfig.extensionname,
sr_api.ExtensionPacket(
messagetype,
serverConfig.extensionname,
data,
serverConfig.channel
),
serverConfig.channel)
)
if (currentAnswer.toLowerCase() == answer.toLowerCase())
{
clearTimeout(localConfig.quizbotTimerHandle)
startQuiz();
}
}
// ============================================================================
// FUNCTION: startQuiz
// ============================================================================
/**
* Starts the quiz running
*/
function startQuiz ()
{
// check if quiz is running
if (typeof (localConfig.quizbotTimerHandle) != "undefined" && !localConfig.quizbotTimerHandle._destroyed)
stopQuiz()
localConfig.currentQuestionAnswer = Math.floor(Math.random() * localConfig.quizQuestions.length);
if (serverConfig.quizbot_showanswerinconsole == "on")
{
let currentQuestion = localConfig.quizQuestions[localConfig.currentQuestionAnswer].split("##")
console.log("currentQuestion", "'" + currentQuestion[0].trim() + "'")
console.log("answer", "'" + currentQuestion[1].trim() + "'")
}
quizTimerCallback();
//timeout here is so we don't get "sending too quickly" when the bot isn't modded/vip
setTimeout(() =>
{
sendQuizStarted();
}, 2000);
}
// ============================================================================
// FUNCTION: stopQuiz
// ============================================================================
/**
* Stops the quiz
* sends trigger_QuizbotQuizStopped
*/
function stopQuiz ()
{
// if we are currently turned on trigger a message so we can post the answer to the last question when turning it off
if (serverConfig.quizbot_enabled == "on" && typeof (localConfig.quizbotTimerHandle) != "undefined" && !localConfig.quizbotTimerHandle._destroyed)
{
let currentQuestion = localConfig.quizQuestions[localConfig.currentQuestionAnswer].split("##")[0].trim();
let currentAnswer = localConfig.quizQuestions[localConfig.currentQuestionAnswer].split("##")[1].trim();
let data = findtriggerByMessageType("trigger_QuizbotQuizStopped")
data.parameters.question = currentQuestion
data.parameters.answer = currentAnswer;
sr_api.sendMessage(localConfig.DataCenterSocket,
sr_api.ServerPacket("ChannelData",
serverConfig.extensionname,
sr_api.ExtensionPacket(
"trigger_QuizbotQuizStopped",
serverConfig.extensionname,
data,
serverConfig.channel),
serverConfig.channel
),
);
}
clearTimeout(localConfig.quizbotTimerHandle)
}
// ============================================================================
// FUNCTION: quizTimeout
// ============================================================================
/**
* sends trigger_QuizbotQuizTimeout when current question timer expires
*/
function quizTimeout ()
{
let currentQuestion = localConfig.quizQuestions[localConfig.currentQuestionAnswer].split("##")[0].trim();
let currentAnswer = localConfig.quizQuestions[localConfig.currentQuestionAnswer].split("##")[1].trim();
let data = findtriggerByMessageType("trigger_QuizbotQuizTimeout")
data.parameters.question = currentQuestion
data.parameters.answer = currentAnswer;
sr_api.sendMessage(localConfig.DataCenterSocket,
sr_api.ServerPacket("ChannelData",
serverConfig.extensionname,
sr_api.ExtensionPacket(
"trigger_QuizbotQuizTimeout",
serverConfig.extensionname,
data,
serverConfig.channel),
serverConfig.channel
),
);
}
// ============================================================================
// FUNCTION: quizTimerCallback
// ============================================================================
/**
* When the timer for the current question expires this is called to start a new question
*/
function quizTimerCallback ()
{
clearTimeout(localConfig.quizbotTimerHandle)
if (serverConfig.quizbot_enabled == "on")
{
localConfig.quizbotTimerHandle = setTimeout(() =>
{
quizTimeout();
startQuiz();
quizTimerCallback();
}, serverConfig.quizbot_duration)
}
}
// ============================================================================
// FUNCTION: findtriggerByMessageType
// ============================================================================
/**
* Finds a trigger by messagetype
* @param {string} messagetype
* @returns trigger
*/
function findtriggerByMessageType (messagetype)
{
for (let i = 0; i < triggersandactions.triggers.length; i++)
{
if (triggersandactions.triggers[i].messagetype.toLowerCase() == messagetype.toLowerCase())
return triggersandactions.triggers[i];
}
logger.err(localConfig.SYSTEM_LOGGING_TAG + serverConfig.extensionname +
".findtriggerByMessageType", "failed to find action", messagetype);
}
// ============================================================================
// FUNCTION: heartBeat
// ============================================================================
/**
* Sends out heartbeat messages so other extensions can see our status
*/
function heartBeatCallback ()
{
let status = {
connected: serverConfig.quizbot_enabled == "on",
color: "red"
}
if (status.connected && typeof (localConfig.quizbotTimerHandle) != "undefined" && localConfig.quizbotTimerHandle._destroyed)
status.color = "orange";
else if (status.connected && typeof (localConfig.quizbotTimerHandle) != "undefined" && !localConfig.quizbotTimerHandle._destroyed)
status.color = "green";
sr_api.sendMessage(localConfig.DataCenterSocket,
sr_api.ServerPacket("ChannelData",
serverConfig.extensionname,
sr_api.ExtensionPacket(
"HeartBeat",
serverConfig.extensionname,
status,
serverConfig.channel),
serverConfig.channel
),
);
localConfig.heartBeatHandle = setTimeout(heartBeatCallback, localConfig.heartBeatTimeout)
}
// ============================================================================
// EXPORTS
// Note that initialise is mandatory to allow the server to start this extension
// ============================================================================
export { initialise, triggersandactions };