@@ -1,3 +1,7 @@ | |||
// Re-set state on startup | |||
const quiz = require('./src/quiz'); | |||
quiz.set_bot_state(); | |||
// Start twitch bot | |||
const twitch = require('./src/twitch'); | |||
twitch.start_bot(); | |||
@@ -9,6 +9,13 @@ CREATE TABLE questions ( | |||
total_correct int, | |||
question text UNIQUE, | |||
answer text, | |||
bonus_slot_1 text, | |||
bonus_slot_2 text, | |||
bonus_slot_3 text, | |||
answer_a_total int, | |||
answer_b_total int, | |||
answer_c_total int, | |||
answer_d_total int, | |||
ended_at TIMESTAMP NOT NULL DEFAULT NOW() | |||
); | |||
@@ -24,5 +31,6 @@ CREATE TABLE answers ( | |||
viewer_id int REFERENCES viewers(id), | |||
question_id int REFERENCES questions(id), | |||
answer text, | |||
points int, | |||
PRIMARY KEY (id, viewer_id, question_id) | |||
); |
@@ -0,0 +1,148 @@ | |||
<html> | |||
<body> | |||
<div><h1>Hi, <span id="username_display">[User]</span>,</h1></div> | |||
<div>Your email address is <span id="email_display">[Email]</span>.</div> | |||
<div>Your email address <strong id="verified_display" style="text-decoration: underline;">[Verified]</strong> been verified.</div> | |||
<hr/> | |||
<div><h1>TwitchTrivia Data</h1></div> | |||
<div><a id="direct_link">Direct Link</a></div> | |||
<div style="display: flex;"> | |||
<div style="padding-right: 40px;"> | |||
<h4>Leaderboard</h4> | |||
<table> | |||
<thead><th>User</th><th>Points</th></thead> | |||
<tbody id="leaderboard_display"> | |||
</tbody> | |||
</table> | |||
</div> | |||
<div style="padding-left: 40px;"> | |||
<h4>Stats</h4> | |||
<table> | |||
<tbody id="stats_display"> | |||
</tbody> | |||
</table> | |||
</div> | |||
</div> | |||
</body> | |||
<script> | |||
window.addEventListener('DOMContentLoaded', (event) => { | |||
refreshData(); | |||
let rid = window.setInterval(refreshData, 20000); | |||
}); | |||
function refreshData() { | |||
/* Parse out the JWT returned by Twitch OIDC login for user data */ | |||
let token_raw = document.location.hash.slice(10); | |||
let token = JSON.parse(atob(token_raw.split('.')[1])); | |||
let username = token.preferred_username; | |||
let email = token.email; | |||
let verified = token.email_verified; | |||
console.log(username, email, verified); | |||
document.querySelector('#username_display').innerText = username; | |||
document.querySelector('#email_display').innerText = email; | |||
document.querySelector('#verified_display').innerText = verified ? 'has' : 'hasn\'t'; | |||
document.querySelector('#direct_link').href = window.location.origin + `/leaderboard/${username}`; | |||
/* Call TwitchTrivia for user data */ | |||
fetch(window.location.origin + `/leaderboard/${username}?format=json`) | |||
.then(response => response.json()) | |||
.then(data => { | |||
populateLeaderboardDisplay(data, username); | |||
}).catch((error) => { | |||
console.error('Error fetching from TwitchTrivia', error); | |||
}); | |||
} | |||
function clearTableData(tbody) { | |||
while(tbody.firstChild) { | |||
tbody.removeChild(tbody.firstChild); | |||
} | |||
}; | |||
function populateLeaderboardDisplay(data, username) { | |||
let tbody = document.querySelector('#leaderboard_display'); | |||
clearTableData(tbody); | |||
for(let leader of data.leaderboard) { | |||
// Create text nodes out of the data | |||
let name = document.createTextNode(leader.username); | |||
let points = document.createTextNode(leader.points); | |||
// Add them to the table | |||
let tr = tbody.insertRow(); | |||
tr.insertCell().appendChild(name); | |||
tr.insertCell().appendChild(points); | |||
if(leader.username == username) { | |||
tr.style = "color: green; text-decoration: underline;"; | |||
} | |||
} | |||
let tr = null; | |||
tbody = document.querySelector('#stats_display'); | |||
clearTableData(tbody); | |||
if(data.active && data.active.active_question) { | |||
// Title | |||
tr = tbody.insertRow(); | |||
tr.insertCell().appendChild(document.createTextNode("CURRENT QUESTION")); | |||
// Q | |||
tr = tbody.insertRow(); | |||
tr.insertCell().appendChild(document.createTextNode("Question")); | |||
tr.insertCell().appendChild(document.createTextNode(data.active.active_question)); | |||
// Bonus Slots | |||
tr = tbody.insertRow(); | |||
tr.insertCell().appendChild(document.createTextNode("First to Answer")); | |||
tr.insertCell().appendChild(document.createTextNode(data.active.bonus_slot_1 || "AVAILABLE!")); | |||
tr = tbody.insertRow(); | |||
tr.insertCell().appendChild(document.createTextNode("Second to Answer")); | |||
tr.insertCell().appendChild(document.createTextNode(data.active.bonus_slot_2 || "AVAILABLE!")); | |||
tr = tbody.insertRow(); | |||
tr.insertCell().appendChild(document.createTextNode("Third to Answer")); | |||
tr.insertCell().appendChild(document.createTextNode(data.active.bonus_slot_3 || "AVAILABLE!")); | |||
// Percents | |||
if(data.active.answer_percents) { | |||
tr = tbody.insertRow(); | |||
tr.insertCell().appendChild(document.createTextNode("Answered A")); | |||
tr.insertCell().appendChild(document.createTextNode(`${data.active.answer_percents.A || 0}% (${data.active.answer_totals.A || 0})`)); | |||
tr = tbody.insertRow(); | |||
tr.insertCell().appendChild(document.createTextNode("Answered B")); | |||
tr.insertCell().appendChild(document.createTextNode(`${data.active.answer_percents.B || 0}% (${data.active.answer_totals.B || 0})`)); | |||
tr = tbody.insertRow(); | |||
tr.insertCell().appendChild(document.createTextNode("Answered C")); | |||
tr.insertCell().appendChild(document.createTextNode(`${data.active.answer_percents.C || 0}% (${data.active.answer_totals.C || 0})`)); | |||
tr = tbody.insertRow(); | |||
tr.insertCell().appendChild(document.createTextNode("Answered D")); | |||
tr.insertCell().appendChild(document.createTextNode(`${data.active.answer_percents.D || 0}% (${data.active.answer_totals.D || 0})`)); | |||
} | |||
} | |||
tr = tbody.insertRow(); | |||
tr.insertCell().appendChild(document.createTextNode("OVERALL")); | |||
// Total Votes | |||
tr = tbody.insertRow(); | |||
tr.insertCell().appendChild(document.createTextNode("Total Votes")); | |||
tr.insertCell().appendChild(document.createTextNode(data.combined_total_responses)); | |||
// Total Correct | |||
tr = tbody.insertRow(); | |||
tr.insertCell().appendChild(document.createTextNode("Total Correct Votes")); | |||
tr.insertCell().appendChild(document.createTextNode(data.combined_total_correct)); | |||
} | |||
</script> | |||
</html> |
@@ -0,0 +1,18 @@ | |||
<html> | |||
<body> | |||
<button onclick=twitchLogin()>Twitch Login</button> | |||
</body> | |||
<script> | |||
function twitchLogin() { | |||
let client_id = "l8oqrowcuszvkbxd84243qmhn86o22"; | |||
let redirect_uri = window.location.origin + "/landing.html"; | |||
let response_type = "id_token"; | |||
let scope = "openid user:read:email"; | |||
let claims = JSON.stringify({"id_token": {"email": null, "email_verified": null, "preferred_username": null}}); | |||
let twitch_url = `https://id.twitch.tv/oauth2/authorize?client_id=${client_id}&redirect_uri=${redirect_uri}&response_type=${response_type}&scope=${scope}&claims=${claims}`; | |||
console.log(twitch_url); | |||
window.location.href = twitch_url; | |||
} | |||
</script> | |||
</html> |
@@ -14,20 +14,22 @@ pool.on('error', (err, client) => { | |||
}); | |||
const config = { | |||
query_create_question: "INSERT INTO questions(question, answer, total_responses, total_valid, total_correct, ended_at) VALUES($1, $2, $3, $4, $5, $6) RETURNING id", | |||
query_create_viewer: "INSERT INTO viewers(username, points) VALUES ($1, $2) ON CONFLICT (username) DO UPDATE SET points = EXCLUDED.points RETURNING id, username", | |||
query_create_answer: "INSERT INTO answers(viewer_id, question_id, answer) VALUES ($1, $2, $3) RETURNING id", | |||
query_create_question: "INSERT INTO questions(question, answer, total_responses, total_valid, total_correct, bonus_slot_1, bonus_slot_2, bonus_slot_3, answer_a_total, answer_b_total, answer_c_total, answer_d_total, ended_at) VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING id", | |||
query_create_viewer: "INSERT INTO viewers(username, points) VALUES ($1, $2) ON CONFLICT (username) DO UPDATE SET points = viewers.points + EXCLUDED.points RETURNING id, username", | |||
query_create_answer: "INSERT INTO answers(viewer_id, question_id, answer, points) VALUES ($1, $2, $3, $4) RETURNING id", | |||
query_all_question_data: "SELECT * FROM questions", | |||
query_all_viewer_data: "SELECT viewers.*, (SELECT Count(*) FROM answers WHERE viewer_id = viewers.id) as total_answers FROM viewers", | |||
query_viewer_data: "SELECT viewers.*, (SELECT Count(*) FROM answers WHERE viewer_id = viewers.id) as total_answers FROM viewers WHERE viewers.username = $1", | |||
query_all_answer_data: "SELECT v.username, a.answer, q.question, CASE a.answer WHEN q.answer THEN true ELSE false END FROM answers a LEFT JOIN viewers v ON a.viewer_id = v.id LEFT JOIN questions q ON a.question_id = q.id", | |||
query_all_answer_data: "SELECT v.username, a.answer, q.question, a.points, CASE a.answer WHEN q.answer THEN true ELSE false END as correct FROM answers a LEFT JOIN viewers v ON a.viewer_id = v.id LEFT JOIN questions q ON a.question_id = q.id", | |||
query_top10_data: "SELECT username, points FROM viewers ORDER BY points DESC LIMIT 10", | |||
query_top10_data: "SELECT username, points FROM viewers ORDER BY points DESC LIMIT 20", | |||
query_totals_data: "SELECT count(id) as total_questions, sum(total_responses) as total_responses, sum(total_valid) as total_valid, sum(total_correct) as total_correct FROM questions", | |||
query_recent_questions: "SELECT * FROM questions ORDER BY ended_at DESC LIMIT 20", | |||
}; | |||
console.log("* [PSQL] Hooked into Postgres backend"); | |||
console.log("* [psql] Hooked into Postgres backend"); | |||
var self = module.exports = { | |||
query: function(querystr, values=undefined) { | |||
@@ -35,7 +37,7 @@ var self = module.exports = { | |||
pool.query(querystr, values).then((data) => { | |||
return resolve(data.rows); | |||
}).catch((error) => { | |||
console.log("* [PSQL] Error performing query", querystr, error); | |||
console.log("* [psql] Error performing query", querystr, error); | |||
}); | |||
}); | |||
}, | |||
@@ -50,10 +52,17 @@ var self = module.exports = { | |||
}); | |||
}, | |||
get_viewer_data: function(username) { | |||
return new Promise((resolve) => { | |||
return new Promise((resolve) => { | |||
self.query(config.query_viewer_data, [username]).then((viewer) => { | |||
resolve(viewer); | |||
}); | |||
}); | |||
}, | |||
get_recent_questions: function() { | |||
return new Promise((resolve) => { | |||
self.query(config.query_recent_questions).then((recents) => { | |||
resolve(recents); | |||
}); | |||
}); | |||
}, | |||
get_all_question_data: function() { | |||
@@ -61,7 +70,7 @@ var self = module.exports = { | |||
self.query(config.query_all_question_data).then((questions) => { | |||
resolve(questions); | |||
}).catch((error) => { | |||
console.log("* [Psql] Error gathering all question data", error); | |||
console.log("* [psql] Error gathering all question data", error); | |||
}); | |||
}); | |||
}, | |||
@@ -86,10 +95,18 @@ var self = module.exports = { | |||
q.total_responses, | |||
q.total_valid, | |||
q.total_correct, | |||
q.bonus_slot_1, | |||
q.bonus_slot_2, | |||
q.bonus_slot_3, | |||
q.answer_totals['A'] || null, | |||
q.answer_totals['B'] || null, | |||
q.answer_totals['C'] || null, | |||
q.answer_totals['D'] || null, | |||
new Date() | |||
]; | |||
client.query(config.query_create_question, question_values).then((dbq) => { | |||
console.log(`* [Psql] Created new question in Postgres with id ${dbq.rows[0].id}`); | |||
console.log(`* [psql] Created new question in Postgres with id ${dbq.rows[0].id}`); | |||
const question_id = dbq.rows[0].id; | |||
// If there weren't any answers for this question, our job is done | |||
@@ -101,7 +118,7 @@ var self = module.exports = { | |||
// Then insert/update all of the viewers | |||
const viewer_inserts = Object.entries(q.active_points).map((viewer) => { | |||
return client.query(config.query_create_viewer, [viewer[0], viewer[1]]).then((dbv) => { | |||
console.log(`* [psql] Upserted viewer data: ${viewer[0]} = ${dbv.rows[0].id}`); | |||
console.info(`* [psql] Upserted viewer data: ${viewer[0]} = ${dbv.rows[0].id}`); | |||
return dbv.rows[0]; | |||
}).catch((error) => { | |||
console.log(`* [psql] Error upserting viewer ${viewer}`, error); | |||
@@ -117,9 +134,9 @@ var self = module.exports = { | |||
// Now insert all of the viewers answers to this question | |||
const answer_inserts = Object.entries(q.active_votes).map((answer) => { | |||
let answer_value = [viewer_id_map[answer[0]], question_id, answer[1]] | |||
let answer_value = [viewer_id_map[answer[0]], question_id, answer[1], (q.active_points[answer[0]] || 0)]; | |||
return client.query(config.query_create_answer, answer_value).then((dba) => { | |||
console.log(`* [psql] Inserted answer data: ${answer} = ${dba.rows[0].id}`); | |||
console.info(`* [psql] Inserted answer data: ${answer} = ${dba.rows[0].id}`); | |||
return dba.rows[0]; | |||
}).catch((error) => { | |||
console.log(`* [psql] Error upserting answer ${answer}`, error); | |||
@@ -4,6 +4,8 @@ const redis = require('./redis-brain'); | |||
const valid_answers = ['A', 'B', 'C', 'D']; | |||
const bonus_slot_points = [100, 50, 25]; | |||
const standard_points = [20]; | |||
const leaderboard_max = 10; | |||
const personal_leaderboard_max = 10; | |||
var self = module.exports = { | |||
bonus_slot_1: null, | |||
@@ -13,21 +15,25 @@ var self = module.exports = { | |||
endCurrentQuestion: function() { | |||
return new Promise((resolve) => { | |||
redis.getActiveQuestionData(false).then((question_data) => { | |||
console.log(`* [quiz] We got question data: ${JSON.stringify(question_data)}`); | |||
console.log(`* [quiz] Ending the current question`); | |||
// Send all the data off to be saved in the database | |||
db.write_question_data(question_data).catch((error) => { | |||
console.log("* [Quiz] Error writing question data to Postgres"); | |||
console.error("* [Quiz] Error writing question data to Postgres"); | |||
}); | |||
redis.clearActiveQuestion().then(() => { | |||
self.bonus_slot_1 = null; | |||
self.bonus_slot_2 = null; | |||
self.bonus_slot_3 = null; | |||
resolve(); | |||
}).catch((error) => { | |||
console.log("* [Quiz] Error clearing active question"); | |||
console.error("* [Quiz] Error clearing active question"); | |||
}); | |||
}).catch((error) => { | |||
console.log("* [quiz] Error getting active question data from redis"); | |||
console.error("* [quiz] Error getting active question data from redis"); | |||
}); | |||
}); | |||
}, | |||
@@ -37,7 +43,6 @@ var self = module.exports = { | |||
// Weed out immediate faulty votes that aren't (A|B|C|D) | |||
if(!(answer) || !(valid_answers.includes(answer))) { | |||
console.log("We have an invalid answer:", answer); | |||
redis.incrementResponseCounts(false, false); | |||
return resolve(); | |||
} | |||
@@ -55,7 +60,7 @@ var self = module.exports = { | |||
// Either way, store their answer in redis (if they haven't already answered) | |||
redis.addViewerVote(username, answer).catch((err) => { | |||
console.log(`* [quiz] Error setting user vote for ${username}`); | |||
console.error(`* [quiz] Error setting user vote for ${username}`); | |||
}); | |||
}); | |||
@@ -66,9 +71,9 @@ var self = module.exports = { | |||
if(nopoints) { | |||
// If it was the wrong answer | |||
redis.addViewerPoints(username, 0, null).then(() => { | |||
console.log(`* [quiz] Assigning 0 points to ${username} for an incorrect answer`); | |||
console.info(`* [quiz] Assigning 0 points to ${username} for an incorrect answer`); | |||
}).catch((error) => { | |||
console.log(`* [quiz] Error adding no-points for ${username}`, error); | |||
console.error(`* [quiz] Error adding no-points for ${username}`, error); | |||
}); | |||
return; | |||
@@ -95,14 +100,14 @@ var self = module.exports = { | |||
} | |||
redis.addViewerPoints(username, points, used_slot).then(() => { | |||
console.log(`* [quiz] Awarding ${points} points to ${username} for a correct first answer`); | |||
console.info(`* [quiz] Awarding ${points} points to ${username} for a correct first answer`); | |||
}).catch((error) => { | |||
console.log(`* [quiz] Error adding ${points} for ${username}`, error); | |||
console.error(`* [quiz] Error adding ${points} for ${username}`, error); | |||
}); | |||
}, | |||
gatherLeaderboardData: function() { | |||
gatherLeaderboardData: function(username=null) { | |||
return new Promise((resolve) => { | |||
redis.getActiveQuestionData(true).then((active) => { | |||
redis.getActiveQuestionData(false).then((active) => { | |||
if(active && active.active_points) { | |||
// Sort viewers by points descending | |||
active.active_points = Object.entries(active.active_points).sort((a, b) => { | |||
@@ -132,24 +137,22 @@ var self = module.exports = { | |||
data.combined_total_valid = parseInt(data.active.total_valid); | |||
data.combined_total_correct = parseInt(data.active.total_correct); | |||
data.combined_total_incorrect = (parseInt(data.active.total_valid) - parseInt(data.active.total_correct)); | |||
data.totals.total_responses = 0; | |||
data.totals.total_valid = 0; | |||
data.totals.total_correct = 0; | |||
data.totals.total_responses = 0; | |||
data.totals.total_valid = 0; | |||
data.totals.total_correct = 0; | |||
} else if(data.totals.total_responses) { | |||
data.combined_total_responses = parseInt(data.totals.total_responses); | |||
data.combined_total_valid = parseInt(data.totals.total_valid); | |||
data.combined_total_correct = parseInt(data.totals.total_correct); | |||
data.combined_total_incorrect = (parseInt(data.totals.total_valid) - parseInt(data.totals.total_correct)); | |||
data.active.total_responses = 0; | |||
data.active.total_valid = 0; | |||
data.active.total_correct = 0; | |||
data.active.total_responses = 0; | |||
data.active.total_valid = 0; | |||
data.active.total_correct = 0; | |||
} | |||
// Supplement the database leaderboard with any points currently in redis | |||
let leaderboard_names = data.leaderboard.map(x => x.username); | |||
if(active.active_points) { | |||
let leaderboard_names = data.leaderboard.map(x => x.username); | |||
for(let voter of active.active_points) { | |||
if(leaderboard_names.includes(voter[0])) { | |||
for(let leader of data.leaderboard) { | |||
@@ -162,25 +165,58 @@ var self = module.exports = { | |||
} | |||
} | |||
// Re-order the leaderboard | |||
data.leaderboard.sort((a, b) => { | |||
return b.points - a.points; | |||
}); | |||
// Trim it back down to 10 folks total | |||
data.leaderboard = data.leaderboard.slice(0, 10); | |||
} | |||
// let leaderboard_names = data.leaderboard.map(x => x.username); | |||
if(username && !leaderboard_names.includes(username)) { | |||
user_data = db.get_viewer_data(username).then((ud) => { | |||
if(ud.length) { | |||
let found = false; | |||
for(let ld of data.leaderboard) { | |||
if(ld.username == ud[0].username) { | |||
ld.points += ud[0].points; | |||
found = true; | |||
} | |||
} | |||
if(!found) { | |||
data.leaderboard.push({username: ud[0].username, points: ud[0].points}); | |||
} | |||
} | |||
data.leaderboard = cleanLeaderboard(data.leaderboard, username=username); | |||
delete data.active.active_points; | |||
delete data.active.active_votes; | |||
return resolve(data); | |||
}); | |||
return; | |||
} | |||
data.leaderboard = cleanLeaderboard(data.leaderboard, username=username); | |||
delete data.active.active_points; | |||
delete data.active.active_votes; | |||
resolve(data); | |||
}); | |||
}).catch((error) => { | |||
console.log("* [web] Error rendering the leaderboard page", error); | |||
console.log("* [quiz] Error gathering the leaderboard data", error); | |||
res.end(); | |||
}); | |||
}); | |||
}, | |||
gatherRecentQuestions: function() { | |||
return new Promise((resolve) => { | |||
db.get_recent_questions().then((data) => { | |||
for(let q of data) { | |||
q.answer_a_percent = Math.trunc((q.answer_a_total / q.total_valid) * 100); | |||
q.answer_b_percent = Math.trunc((q.answer_b_total / q.total_valid) * 100); | |||
q.answer_c_percent = Math.trunc((q.answer_c_total / q.total_valid) * 100); | |||
q.answer_d_percent = Math.trunc((q.answer_d_total / q.total_valid) * 100); | |||
} | |||
resolve({recents: data}); | |||
}); | |||
}); | |||
}, | |||
generateFakeAnswerData: function() { | |||
return new Promise((resolve) => { | |||
function randomAnswer() { | |||
@@ -206,4 +242,31 @@ var self = module.exports = { | |||
self.handleViewerVote("fuzzyfirefly", randomAnswer()); | |||
}); | |||
}, | |||
set_bot_state: function() { | |||
return new Promise((resolve) => { | |||
redis.checkBonusSlotAvailability().then((bs_data) => { | |||
self.bonus_slot_1 = bs_data[0]; | |||
self.bonus_slot_2 = bs_data[1]; | |||
self.bonus_slot_3 = bs_data[2]; | |||
}); | |||
}); | |||
}, | |||
}; | |||
function cleanLeaderboard(lb, username=null) { | |||
// Re-order the leaderboard | |||
lb.sort((a, b) => { | |||
return b.points - a.points; | |||
}); | |||
if(username) { | |||
for(let i = (personal_leaderboard_max - 1); i < lb.length; i++) { | |||
if(lb[i].username === username) { | |||
lb[personal_leaderboard_max - 1] = lb[i]; | |||
} | |||
} | |||
} | |||
// Trim it back down to 10 folks total | |||
return lb.slice(0, username ? personal_leaderboard_max : leaderboard_max); | |||
} |
@@ -13,6 +13,7 @@ const config = { | |||
total_responses_key: "twitch_bot_total_responses", | |||
total_valid_key: "twitch_bot_total_valid_answers", | |||
total_correct_key: "twitch_bot_total_correct_answers", | |||
answer_totals: "twitch_bot_answer_totals_hash", | |||
}; | |||
client.on("error", function(error) { | |||
@@ -47,6 +48,7 @@ var self = module.exports = { | |||
.del(config.total_responses_key, config.output) | |||
.del(config.total_valid_key, config.output) | |||
.del(config.total_correct_key, config.output) | |||
.del(config.answer_totals, config.output) | |||
.exec(function(err, replies) { | |||
resolve({cleared: true}); | |||
}); | |||
@@ -87,39 +89,41 @@ var self = module.exports = { | |||
.get(config.total_responses_key) | |||
.get(config.total_valid_key) | |||
.get(config.total_correct_key) | |||
.hgetall(config.answer_totals) | |||
.exec(function(err, replies) { | |||
var answer_totals = {}; | |||
var answer_percents = {}; | |||
if(replies[10]) { | |||
answer_totals = Object.fromEntries(Object.entries(replies[10]).map(([k, v]) => [k, Number(v)])); | |||
answer_percents = calculateAnswerPercents(answer_totals) | |||
} | |||
var answer_percents = replies[10] ? calculateAnswerPercents(replies[10]) : null; | |||
// Turn array into a dict for easier visual parsing | |||
if(trimmed) { | |||
return resolve({ | |||
active_question: replies[0], | |||
active_answer: replies[1], | |||
bonus_slot_1: replies[2], | |||
bonus_slot_2: replies[3], | |||
bonus_slot_3: replies[4], | |||
total_responses: replies[7], | |||
total_valid: replies[8], | |||
total_correct: replies[9], | |||
total_incorrect: (replies[8] - replies[9]).toString(), | |||
}); | |||
} | |||
resolve({ | |||
let data = { | |||
active_question: replies[0], | |||
active_answer: replies[1], | |||
bonus_slot_1: replies[2], | |||
bonus_slot_2: replies[3], | |||
bonus_slot_3: replies[4], | |||
active_votes: replies[5], | |||
active_points: replies[6], | |||
total_responses: replies[7], | |||
total_valid: replies[8], | |||
total_correct: replies[9], | |||
total_responses: replies[7] || 0, | |||
total_valid: replies[8] || 0, | |||
total_correct: replies[9] || 0, | |||
total_incorrect: (replies[8] - replies[9]).toString(), | |||
}); | |||
answer_totals: answer_totals, | |||
answer_percents: answer_percents, | |||
}; | |||
if(!trimmed) { | |||
data.active_votes = replies[5]; | |||
data.active_points = replies[6]; | |||
} | |||
resolve(data); | |||
}); | |||
}); | |||
}, | |||
/*** Bot specific actions ***/ | |||
addViewerVote: function(username, answer) { | |||
return new Promise((resolve) => { | |||
@@ -130,7 +134,10 @@ var self = module.exports = { | |||
console.log(`* [redis] Error setting a viewer vote: ${err}`); | |||
return; | |||
} | |||
resolve(); | |||
client.hincrby(config.answer_totals, answer, 1, function(ret) { | |||
resolve(); | |||
}); | |||
}); | |||
}); | |||
}, | |||
@@ -170,7 +177,7 @@ var self = module.exports = { | |||
var correct = (submitted_answer == q.answer); | |||
self.hasUserVoted(username).then((has_voted) => { | |||
if(correct && has_voted) { | |||
console.log(`* [redis] Answer invalided, user ${username} already voted`); | |||
console.info(`* [redis] Answer invalided, user ${username} already voted`); | |||
} | |||
resolve((correct && !has_voted)); | |||
}); | |||
@@ -226,3 +233,18 @@ var self = module.exports = { | |||
}); | |||
}, | |||
}; | |||
function calculateAnswerPercents(answer_totals) { | |||
let total = Object.values(answer_totals).map(Number).reduce(function(total, answer) { | |||
return total + answer; | |||
}); | |||
let percents = {}; | |||
for([key, value] of Object.entries(answer_totals)) { | |||
// percents[key] = Number.parseFloat(((Number(value)/total) * 100).toFixed(2)); | |||
percents[key] = Math.trunc((Number(value)/total) * 100); | |||
} | |||
return percents; | |||
} |
@@ -19,7 +19,7 @@ const opts = { | |||
}; | |||
// Create a client with our options | |||
console.log("* [TWITCH] Bot starting up..."); | |||
console.log("* [twitch] Bot starting up..."); | |||
const client = new tmi.client(opts); | |||
// Hook into redis brain | |||
@@ -33,7 +33,7 @@ var self = module.exports = { | |||
// Connect to Twitch: | |||
client.connect(); | |||
console.log("* [TWITCH] Bot connected and listening"); | |||
console.log("* [twitch] Bot connected and listening"); | |||
} | |||
}; | |||
@@ -59,8 +59,8 @@ function onMessageHandler (target, context, msg, self) { | |||
function onWhisperHandler(target, context, msg) { | |||
if (msg.startsWith('!submitfakes')) { | |||
quiz.generateFakeAnswerData().then((resolve) => { | |||
console.log("* [bot] Generating fake answer data for testing purposes"); | |||
}).catch((error) => { console.log("* [bot] Error attempting to generate fake data", error); }); | |||
console.log("* [twitch] Generating fake answer data for testing purposes"); | |||
}).catch((error) => { console.log("* [twitch] Error attempting to generate fake data", error); }); | |||
} | |||
if (msg.startsWith('!submitfake ')) { | |||
@@ -68,28 +68,22 @@ function onWhisperHandler(target, context, msg) { | |||
let username = msg.slice(14); | |||
quiz.handleViewerVote(username, answer).then((resolve) => { | |||
console.log(`* [bot] Faked a vote of ${answer} from ${username} via whisper`); | |||
console.log(`* [twitch] Faked a vote of ${answer} from ${username} via whisper`); | |||
}).catch((error) => { | |||
console.log(`* [bot] Error attempting to fake a vote via whisper`); | |||
console.log(`* [twitch] Error attempting to fake a vote via whisper`); | |||
}); | |||
} | |||
} | |||
function onChatHandler(target, context, msg) { | |||
// If the command is known, let's execute it | |||
if (msg === '!dice') { | |||
const num = rollDice(); | |||
client.say(target, `You rolled a ${num}`); | |||
console.log(`* Executed ${msg} command`); | |||
} else if (msg.startsWith(opts.votecommand)) { | |||
if (msg.startsWith(opts.votecommand)) { | |||
var answer = msg.replace(opts.votecommand, ''); | |||
quiz.handleViewerVote(context.username, answer).then((resolve, reject) => { | |||
console.log(`* [bot] Got a vote from ${context.username} (${answer.trim()})`); | |||
// console.log(`* [bot] Got a vote from ${context.username} (${answer.trim()})`); | |||
}).catch((err) => { | |||
console.log(" [bot] Error handling viewer vote", err); | |||
}); | |||
} else { | |||
console.log(`* Unknown command ${msg}`); | |||
} | |||
} | |||
@@ -101,6 +95,6 @@ function rollDice () { | |||
// Called every time the bot connects to Twitch chat | |||
function onConnectedHandler (addr, port) { | |||
console.log(`* Connected to ${addr}:${port}`); | |||
console.log(`* [twitch] Connected to ${addr}:${port}`); | |||
} | |||
@@ -26,14 +26,17 @@ var self = module.exports = { | |||
app.get('/', renderRoot); | |||
app.post('/', handlePost); | |||
app.get('/leaderboard', renderLeaderboard); | |||
// API json responses | |||
app.get('/questions', renderQuestions); | |||
app.get('/viewers', renderViewers); | |||
app.get('/viewer/:username', renderViewer); | |||
app.get('/leaderboard/:username', renderPersonalLeaderboard); | |||
app.get('/answers', renderAnswers); | |||
app.get('/leaderboard', renderLeaderboard); | |||
app.get('/recent', renderRecentQuestions); | |||
// Debug endpoints | |||
app.get('/redis', renderRedisView); | |||
@@ -44,7 +47,7 @@ var self = module.exports = { | |||
} | |||
function renderRoot(req, res) { | |||
redis.getActiveQuestionData().then((values) => { | |||
redis.getActiveQuestionData(true).then((values) => { | |||
if(values && values.active_points) { | |||
// Sort viewers by points descending | |||
values.active_points = Object.entries(values.active_points).sort((a, b) => { | |||
@@ -59,6 +62,30 @@ function renderRoot(req, res) { | |||
}); | |||
} | |||
function handlePost(req, res) { | |||
if(req.body.clear_question) { | |||
console.log('* [web] Clearing the active question'); | |||
clearQuestion(req, res); | |||
} else { | |||
addQuestion(req, res); | |||
} | |||
} | |||
function renderRecentQuestions(req, res) { | |||
quiz.gatherRecentQuestions().then((data) => { | |||
if(req.query.format == "json") { | |||
res.json(data); | |||
return; | |||
} | |||
res.render('recent.html', data=data); | |||
}).catch((error) => { | |||
console.log("* [web] Error rendering the recent questions page", error); | |||
res.end(); | |||
}); | |||
} | |||
function renderLeaderboard(req, res) { | |||
quiz.gatherLeaderboardData().then((data) => { | |||
if(req.query.format == "json") { | |||
@@ -73,6 +100,23 @@ function renderLeaderboard(req, res) { | |||
}); | |||
} | |||
function renderPersonalLeaderboard(req, res) { | |||
if(!req.params.username) { | |||
res.json({"error": "No username provided"}); | |||
} | |||
quiz.gatherLeaderboardData(req.params.username).then((data) => { | |||
data.username = req.params.username; | |||
if(req.query.format == "json") { | |||
res.json(data); | |||
return; | |||
} | |||
res.render('leaderboard.html', data); | |||
}); | |||
} | |||
function renderQuestions(req, res) { | |||
db.get_all_question_data().then((questions) => { | |||
res.json(questions); | |||
@@ -90,8 +134,8 @@ function renderViewer(req, res) { | |||
res.json({"error": "No username provided"}); | |||
} | |||
db.get_viewer_data(req.params.username).then((viewers) => { | |||
res.json(viewers); | |||
db.get_viewer_data(req.params.username).then((viewer) => { | |||
res.json(viewer); | |||
}); | |||
} | |||
@@ -101,27 +145,18 @@ function renderAnswers(req, res) { | |||
}); | |||
} | |||
function handlePost(req, res) { | |||
if(req.body.clear_question) { | |||
console.log('* [web] Clearing the active question'); | |||
clearQuestion(req, res); | |||
} else { | |||
addQuestion(req, res); | |||
} | |||
} | |||
function clearQuestion(req, res) { | |||
quiz.endCurrentQuestion().then((resolve, reject) => { | |||
renderRoot(req, res); | |||
res.redirect('/'); | |||
// renderRoot(req, res); | |||
}).catch((error) => { | |||
res.end(); | |||
}); | |||
} | |||
function addQuestion(req, res) { | |||
console.log(`* [web] New Question Submitted: ${req.body.new_question} - ${req.body.new_answer}`); | |||
redis.setActiveQuestion(req.body.new_question, req.body.new_answer).then((resolve, reject) => { | |||
renderRoot(req, res); | |||
res.redirect('/'); | |||
}).catch((error) => { | |||
res.end(); | |||
}); | |||
@@ -1,15 +1,18 @@ | |||
{% set navigation_bar = [ | |||
{href: '/', id: 'index', label: 'Active Question'}, | |||
{href: '/leaderboard', id: 'leaderboard', label: 'Leaderboard'} | |||
{href: '/leaderboard', id: 'leaderboard', label: 'Leaderboard'}, | |||
{href: '/recent', id: 'recent', label: 'Recent Questions'} | |||
] %} | |||
{% set api_nav = [ | |||
{href: '/redis', label: 'Active Question'}, | |||
{href: '/leaderboard?format=json', label: 'Leaderboard'}, | |||
{href: '/questions', label: 'Questions'}, | |||
{href: '/viewers', label: 'Participants'}, | |||
{href: '/answers', label: 'Answers'}, | |||
{href: '/redis', label: 'Active Question'}, | |||
{href: '#', label: '/viewer/:username'} | |||
{href: '/viewers', label: 'Participants'}, | |||
{href: '/recent', label: 'Recent Question Stats'}, | |||
{href: '/leaderboard/:username?format=json', label: 'User Leaderboard'}, | |||
{href: '/viewer/:username', label: 'User Data'} | |||
] %} | |||
{% set active_page = active_page|default('index') %} | |||
@@ -17,9 +20,9 @@ | |||
<head> | |||
<meta charset="utf-8"> | |||
<meta name="viewport" content="width=device-width, initial-scale=1"> | |||
<link rel="stylesheet" type="text/css" href="css/uikit.min.css" /> | |||
<script src="js/uikit.min.js"></script> | |||
<script src="js/uikit-icons.min.js"></script> | |||
<link rel="stylesheet" type="text/css" href="/css/uikit.min.css" /> | |||
<script src="/js/uikit.min.js"></script> | |||
<script src="/js/uikit-icons.min.js"></script> | |||
<title>Twitch Trivia</title> | |||
</head> | |||
@@ -52,8 +55,8 @@ | |||
{% block content %} | |||
{% endblock %} | |||
</div> | |||
<div class="uk-margin uk-panel uk-text-center"> | |||
Created by <a href="https://binaryatrocity.name">binaryatrocity</a> - © 2020 | |||
<div class="uk-margin uk-panel uk-text-center uk-text-muted"> | |||
Created by <a class="uk-text-muted" href="https://binaryatrocity.name">binaryatrocity</a> - © 2020 | |||
</div> | |||
</body> | |||
</html> |
@@ -20,24 +20,51 @@ | |||
<hr class="uk-divider-icon" /> | |||
<div class="uk-margin uk-flex uk-flex-around"> | |||
<div> | |||
<div class="uk-text-emphasis uk-text-bold">Total Responses</div> | |||
<div class="uk-text-bold">Total Responses</div> | |||
<div class="uk-text-center">{{ total_responses }}</div> | |||
</div> | |||
<div> | |||
<div class="uk-text-emphasis uk-text-bold">Total Valid</div> | |||
<div class="uk-text-bold">Total Valid</div> | |||
<div class="uk-text-center">{{ total_valid }}</div> | |||
</div> | |||
<div> | |||
<div class="uk-text-emphasis uk-text-bold">Total Correct</div> | |||
<div class="uk-text-bold">Total Correct</div> | |||
<div class="uk-text-center">{{ total_correct }}</div> | |||
</div> | |||
<div> | |||
<div class="uk-text-emphasis uk-text-bold">Total Incorrect</div> | |||
<div class="uk-text-bold">Total Incorrect</div> | |||
<div class="uk-text-center">{{ total_incorrect }}</div> | |||
</div> | |||
</div> | |||
<hr class="uk-divider-icon" /> | |||
<div class="uk-margin"> | |||
{% if answer_percents %} | |||
<div class="uk-margin uk-flex uk-flex-around"> | |||
{% for ap in answer_percents | dictsort %} | |||
<div class="uk-text-center"> | |||
<div class="uk-text-bold">{{ ap[0] }}</div> | |||
<div class="{% if ap[0] == active_answer %}uk-text-warning{% endif %}">{{ ap[1] }}% ({{ answer_totals[ap[0]] }})</div> | |||
</div> | |||
{% endfor %} | |||
</div> | |||
<hr class="uk-divider-icon" /> | |||
{% endif %} | |||
<div class="uk-margin uk-flex uk-flex-around"> | |||
<div class="uk-text-center"> | |||
<div class="uk-text-bold">1st</div> | |||
<div>{{ bonus_slot_1 or "Open" }}</div> | |||
</div> | |||
<div class="uk-text-center"> | |||
<div class="uk-text-bold">2nd</div> | |||
<div>{{ bonus_slot_2 or "Open" }}</div> | |||
</div> | |||
<div class="uk-text-center"> | |||
<div class="uk-text-bold">3rd</div> | |||
<div>{{ bonus_slot_3 or "Open"}}</div> | |||
</div> | |||
</div> | |||
<hr class="uk-divider-icon" /> | |||
<div class="uk-margin" hidden> | |||
<table class="uk-table"> | |||
<caption>Current Votes by Viewer</caption> | |||
<thead> | |||
@@ -65,7 +92,7 @@ | |||
<form method="post"> | |||
<div class="uk-margin"> | |||
<label for="new_question">Question:</label> | |||
<input name="new_question" class="uk-input" type="text"> | |||
<input name="new_question" class="uk-input" type="text" required> | |||
</div> | |||
<div class="uk-margin"> | |||
@@ -4,8 +4,12 @@ | |||
{% block content %} | |||
<div class="uk-panel uk-width-1-2"> | |||
<h1>Leaderboard Data</h1> | |||
<div class="uk-margin"> | |||
<h3>Overall Totals</h3> | |||
{% if username %} | |||
<h2 class="uk-text-muted uk-margin-small-top">{{ username }}</h2> | |||
{% endif %} | |||
<div class="uk-margin uk-margin-large-top"> | |||
<h3 class="uk-text-center">All Questions</h3> | |||
<div class="uk-margin uk-flex uk-flex-around"> | |||
<div> | |||
<div class="uk-text-emphasis uk-text-bold">Responses</div> | |||
@@ -29,7 +33,7 @@ | |||
{% if active %} | |||
<div class="uk-margin"> | |||
<h3>Active Question Totals</h3> | |||
<h3 class="uk-text-center">Current Question</h3> | |||
<div class="uk-margin uk-flex uk-flex-around"> | |||
<div> | |||
<div class="uk-text-emphasis uk-text-bold">Responses</div> | |||
@@ -52,7 +56,7 @@ | |||
<hr class="uk-divider-icon" /> | |||
{% endif %} | |||
<div class="uk-margin"> | |||
<div class="uk-margin" hidden> | |||
<h3>Historical Question Totals</h3> | |||
<div class="uk-margin uk-flex uk-flex-around"> | |||
<div> | |||
@@ -73,7 +77,7 @@ | |||
</div> | |||
</div> | |||
</div> | |||
<hr class="uk-divider-icon" /> | |||
<hr class="uk-divider-icon" hidden/> | |||
<div class="uk-margin"> | |||
<table class="uk-table"> | |||
@@ -87,7 +91,7 @@ | |||
<tbody> | |||
{% if leaderboard %} | |||
{% for viewer in leaderboard %} | |||
<tr> | |||
<tr {% if username and username == viewer.username %}class="uk-text-success"{% endif %}> | |||
<td>{{ viewer.username }}</td> | |||
<td>{{ viewer.points }}</td> | |||
</tr> | |||
@@ -0,0 +1,38 @@ | |||
{% extends "base.html" %} | |||
{% set active_page = "recent" %} | |||
{% block content %} | |||
<div class="uk-panel uk-width-1-1"> | |||
<div class="uk-margin"> | |||
<table class="uk-table uk-table-middle uk-table-hover"> | |||
<caption>Recent Questions</caption> | |||
<thead> | |||
<tr> | |||
<th>Question</th> | |||
<th>Answer</th> | |||
<th>Total Responses</th> | |||
<th>Total Valids</th> | |||
<th>A</th> | |||
<th>B</th> | |||
<th>C</th> | |||
<th>D</th> | |||
</tr> | |||
</thead> | |||
<tbody> | |||
{% for recent in recents %} | |||
<tr> | |||
<td title="{{recent.question}}" class="uk-text-truncate">{{ recent.question }}</td> | |||
<td>{{ recent.answer }}</td> | |||
<td>{{ recent.total_responses }}</td> | |||
<td>{{ recent.total_valid }}</td> | |||
<td>{{ recent.answer_a_percent}}% ({{ recent.answer_a_total }})</td> | |||
<td>{{ recent.answer_b_percent}}% ({{ recent.answer_b_total }})</td> | |||
<td>{{ recent.answer_c_percent}}% ({{ recent.answer_c_total }})</td> | |||
<td>{{ recent.answer_d_percent}}% ({{ recent.answer_d_total }})</td> | |||
</tr> | |||
{% endfor %} | |||
</tbody> | |||
</table> | |||
</div> | |||
</div> | |||
{% endblock %} |