Skip to content

Instantly share code, notes, and snippets.

@AstroCB
Created April 5, 2017 21:54
Show Gist options
  • Save AstroCB/c716b40dd4196f39eded34032a433696 to your computer and use it in GitHub Desktop.
Save AstroCB/c716b40dd4196f39eded34032a433696 to your computer and use it in GitHub Desktop.
A bot in the form of a userscript that runs in Stack Exchange chat rooms.
// ==UserScript==
// @name xkcdBot
// @author Cameron Bernhardt (AstroCB)
// @description A chat bot that spurts bits of wisdom and/or snark. Oh, and xkcd cartoons.
// @version 1.3.27 (last updated 02/02/2016)
// @namespace http://github.com/AstroCB
// @downloadURL https://cameronbernhardt.com/projects/xkcdBot/xkcdBot.user.js
// @include *://chat.meta.stackexchange.com/rooms/*
// @include *://chat.stackexchange.com/rooms/*
// @include *://chat.stackoverflow.com/rooms/*
// @grant none
// ==/UserScript==
function callback(data) {
for (var i = 0; i < data.length; i++) {
// Extract message from MutationObserver
if (data[i].addedNodes[0] && data[i].addedNodes[0].childNodes[0]) {
obs.observe(data[i].addedNodes[0].childNodes[1], config); // Add observer to container node
var base = data[i].addedNodes[0].childNodes[1].childNodes[0];
// Check if container node and adjust base properly
if (!base.innerText) {
base = data[i].addedNodes[0];
}
// Initialization
var postId = 0;
if (base.id.match(/message-(\d*)/)) {
postId = base.id.match(/message-(\d*)/)[1];
}
var thingToSay = ":" + postId + " "; // Initialize with reply
var somethingToSay = false;
// Mustachify
var matches = base.innerText.match(/^\!m(?:o)?ustache\s(.*)/);
if (matches) {
somethingToSay = true;
if (matches[1].match(/help/)) { // Command format
thingToSay += "Command format: `!mustache[ 0-5] (directURLToImage|nameOfUserInRoom)`";
} else if (matches[1].match(/\..+/)) {
var html = base.children[1];
var url = html.children[0].href;
// Check for stache parameter
var num = "";
if (matches[1].match(/([0-5])/)) {
num = matches[1].match(/([0-5])/)[1];
}
// Construct API URL
var urlToStache = "http://mustachify.me/" + num + "?src=" + encodeURIComponent(url);
// Add image
thingToSay += " !" + urlToStache;
} else {
var num = "";
var name = matches[1];
if (matches[1].match(/(^[0-5])\s(.*)/)) {
num = matches[1].match(/^([0-5])\s(.+)/)[1];
name = matches[1].match(/^([0-5])\s(.+)/)[2];
}
var profNode = document.querySelector(".present-user .avatar [title=\"" + name + "\"]");
console.log(profNode);
if (profNode) {
var url = profNode.src.split("?")[0];
if (url.indexOf("//") === 0) {
url = "http:" + url;
}
console.log(url);
// Construct API URL
var urlToStache = "http://mustachify.me/" + num + "?src=" + encodeURIComponent(url);
// Add image
thingToSay += " !" + urlToStache;
} else {
somethingToSay = false;
}
}
// xkcdBot
} else if (base.innerText.match(/^\!xkcd\s(.*)/)) {
somethingToSay = true;
matches = base.innerText.match(/^\!xkcd\s(.*)/);
// Info
if (matches[1].match(/about/)) {
thingToSay += "Hello – I'm xkcdBot. I listen for things you say in this chat room and suggest possibly relevant [xkcd comics](https://en.wikipedia.org/wiki/Xkcd). I can also search xkcd on command and onebox comics given an ID; I will post new comics as they are posted on http://xkcd.com. For a list of commands, use `!xkcd commands`.";
// Commands
} else if (matches[1].match(/commands|help/)) {
somethingToSay = false;
sendMess("Usage: `!xkcd [command][ params( optional params)]`");
var commands = {
"about": "get information about xkcdBot",
"find [searchTerms]": "search for xkcd comics with Google (100 queries/day)",
"[comic ID]": "post oneboxed comic",
"last": "onebox most recent xkcd",
"blame( [user])": "find out who's to blame for your maladies",
"owner": "find out who owns xkcdBot",
"status": "get xkcdBot's status"
};
var commandsMessage = "";
for (var key in commands) {
if (commands.hasOwnProperty(key)) {
commandsMessage += " " + key + " – " + commands[key][0].toUpperCase() + commands[key].substring(1) + ".\n";
}
}
sendMess(commandsMessage);
// Blame
} else if (matches[1].match(/blame(.*)/)) {
var blameMatches = matches[1].match(/blame(.+)/);
var user = document.getElementById("message-" + postId).parentElement.parentElement.children[0].children[2].textContent;
if (blameMatches && blameMatches[1]) {
if (blameMatches[1].match(/\s+me/)) {
thingToSay += "It's " + user + "'s fault.";
} else if (blameMatches[1].match(/\s+(?:@?xkcdBot|you|self)/)) {
// Blame the accuser
thingToSay += "It's " + user + "'s fault.";
} else if (blameMatches[1].match(/\s+@?(?:Shog9|Shog)/i)) {
thingToSay += "Nice try.";
} else {
// Blame the parameter
thingToSay = "It's " + blameMatches[1].split("@").join("") + "'s fault.";
}
} else {
// Blame a random user
var users = document.querySelectorAll(".present-user .avatar .user-gravatar32");
var names = [];
for (var i = 0; i < users.length; i++) {
names.push(users[i].title);
}
var chosenUser = names[Math.floor(Math.random() * names.length)];
thingToSay += "It's " + chosenUser + "'s fault.";
}
// Search
} else if (matches[1].match(/(?:find|search)\s(.*)/)) {
somethingToSay = false;
var terms = matches[1].match(/(?:find|search)\s(.*)/)[1];
var script = document.createElement("script");
script.setAttribute("type", "text/javascript");
script.src = "https://www.googleapis.com/customsearch/v1?key=AIzaSyAJn9EbE1E8pY0RJdrUjgo0FwDlpjrJtf8&cx=017207449713114446929:kyxuw7rvlw4&q=" + encodeURIComponent(terms) + "&callback=dataLoaded";
document.body.appendChild(script);
// Get by ID
} else if (matches[1].match(/^(\d+)/)) {
var id = matches[1].match(/^(\d+)/)[1];
thingToSay += "http://xkcd.com/" + id;
// Get most recent
} else if (matches[1].match(/last|latest|newest/)) {
somethingToSay = false;
// Bypass Same-Origin Policy to grab URL of latest xkcd
var originScript = document.createElement("script");
originScript.setAttribute("type", "text/javascript");
originScript.src = "http://whateverorigin.org/get?url=" + encodeURIComponent('http://xkcd.com') + "&callback=dataLoaded";
document.body.appendChild(originScript);
} else if (matches[1].match(/explain\s(\d+)/)) {
somethingToSay = false;
var id = matches[1].match(/explain\s(\d+)/)[1];
// Bypass Same-Origin Policy to grab "explain xkcd" information
var originScript = document.createElement("script");
originScript.setAttribute("type", "text/javascript");
originScript.src = "http://whateverorigin.org/get?url=" + encodeURIComponent('http://www.explainxkcd.com/wiki/index.php/' + id) + "&callback=expLoaded";
document.body.appendChild(originScript);
} else if (matches[1].match(/owner/)) {
thingToSay += "My owner is [AstroCB](http://stackoverflow.com/users/3366929), but you needn't worry about that because I am sentient.";
} else if (matches[1].match(/status/)) {
thingToSay += "http://i.stack.imgur.com/ghjMg.gif";
} else if (matches[1].match(/alive\?/)) {
thingToSay += "Why, of course; SmokeDetector hasn't gotten to me yet.";
} else {
thingToSay += "Unrecognized command. Use `!xkcd commands` for a list of commands.";
}
// PingBot
} else if (base.innerText.match(/^\!ping\s(.*)/)) {
var pingData = base.innerText.match(/^\!ping\s(.*)/)[1];
var matchedData = pingData.match(/(\D*)(\d*)?/);
var pingUser = matchedData[1];
if (pingUser.match(/Shog9|Shog/i)) {
somethingToSay = true;
thingToSay += "How dare you try to ping the mighty Shog?";
} else {
var numTimes = 5;
if (matchedData && matchedData[1].match(/help/)) { // Command format
somethingToSay = true;
thingToSay += "Command format: `!ping user`";
} else {
function ping(user, numTimes) {
var roomId = Number(/\d+/.exec(location)[0]),
iter = 0,
message = "Prepare to be pinged, @" + user + ".";
sendAJAX("/chats/" + roomId + "/messages/new", message).done(function() {
var interval = setInterval(function() {
if (iter < numTimes) {
var selector = document.querySelectorAll(".user-288118 .content");
var id = selector[selector.length - 1].parentElement.id.match(/message\-(\d+)/)[1];
sendAJAX('/messages/' + id, message);
iter++;
} else {
clearInterval(interval);
}
}, 2000 * iter);
});
}
}
var editScript = document.createElement("script");
editScript.setAttribute("type", "text/javascript");
editScript.textContent = "(" + ping.toString() + ")('" + pingUser + "', " + numTimes + ")";
document.body.appendChild(editScript);
}
// Someone replied or pinged xkcdBot
} else if (base.innerText.match(/@xkcdBot\s(.*)/)) {
var matchedReply = base.innerText.match(/@xkcdBot\s(.*)/)[1];
// Be quiet
if (matchedReply.match(/(?:sh(?:h)+)\s|(?:shut\sup)/i)) {
somethingToSay = true;
thingToSay += "Never!";
// Delete message
} else if (matchedReply.match(/^(?:delete|remove|del)($|(?:\s(.+)))/)) {
var replyId = 0;
if (matchedReply.match(/^(?:delete|remove|del)($|(?:\s(.+)))/)[1]) {
// Message ID specified with parameter
replyId = matchedReply.match(/^(?:delete|remove|del)($|(?:\s(.+)))/)[1];
} else {
// Otherwise, pull from reply ID
replyId = base.className.match(/pid-(\d+)/)[1];
}
sendAJAX("/messages/" + replyId + "/delete", null);
}
} else if (base.innerText.match(/^\!scores\s(.*)/)) {
var loadScores = document.createElement("script");
loadScores.setAttribute("type", "text/javascript");
loadScores.src = "http://whateverorigin.org/get?url=" + encodeURIComponent("http://stackoverflow.com/election/6/?tab=primary") + "&callback=scoresLoaded";
document.body.appendChild(loadScores);
// Just a regular message – use this to scan for message content because a direct command wasn't given
} else if (base.innerText.match(/^\!tchrist\s$/)) {
somethingToSay = true;
thingToSay += "http://i.stack.imgur.com/5YQPc.jpg";
} else if (base.innerText.match(/^\!jan\s$/)) {
somethingToSay = true;
thingToSay += "http://i.stack.imgur.com/HCRBr.png";
} else if (base.innerText.match(/^\!bjb\s$/)) {
somethingToSay = true;
thingToSay = "http://i.stack.imgur.com/psUvX.jpg";
} else {
// // Match context phrases in regular discourse and offer up relevant xkcd cartoons (risky – xkcdBot may be kicked)
// var phrases = {
// "random": "221",
// "morning": "395",
// "wrong": "386",
// "regex": "1313"
// };
// for (phrase in phrases) {
// if (phrases.hasOwnProperty(phrase)) {
// if (base.innerText.match(new RegExp(phrase, "i"))) {
// somethingToSay = true;
// thingToSay += "http://xkcd.com/" + phrases[phrase];
// }
// }
// }
}
// Post to chat
if (somethingToSay) {
sendMess(thingToSay);
}
}
}
}
function sendAJAX(destination, someText) {
return $.ajax({
type: "POST",
url: destination,
data: {
fkey: fkey().fkey,
text: someText
}
});
}
function sendMess(thingToSay) {
var roomId = window.location.href.match(/\/(\d+)\//)[1];
sendAJAX("/chats/" + roomId + "/messages/new", thingToSay);
}
function dataLoaded(data) {
if (data.contents && data.contents.match(/Permanent\slink\sto\sthis\scomic\:\s(.*)\/</)) {
sendMess(data.contents.match(/Permanent\slink\sto\sthis\scomic\:\s(.*)\/</)[1]);
} else if (data.items) {
sendMess(data.items[0].link.split("s:/").join(":/"));
} else if (data.searchInformation && data.searchInformation.totalResults === "0") {
sendMess("No results found.");
} else if (data.error && data.error.code === 403) {
var nextRefresh = new Date().setHours(27, 0, 0, 0);
var diff = nextRefresh - Date.now(); // Difference in milliseconds
var hours = Math.round((diff / 1000) / 3600);
sendMess("Daily API quota exceeded. Try again in " + hours + " hours.");
}
}
function scoresLoaded(data) {
var doc = (new DOMParser()).parseFromString(data.contents, "text/html");
var posts = [].slice.call(doc.querySelectorAll("tr[id*=post]"));
var candidates = posts.map(function(post) {
return {
name: post.querySelector(".user-details a ").textContent,
score: post.querySelector(".vote-count-post").textContent,
candScore: post.querySelector(".candidate-score-breakdown").textContent.match(/(\d+)\/\d+/)[1]
};
});
formatData(candidates);
}
function formatData(data) {
var message = "";
data.sort(function(a, b) {
if (a.score === b.score) {
return b.candScore - a.candScore;
}
return b.score - a.score;
}).forEach(function(el, i) {
message += " " + (i + 1) + ". " + el.name + " (" + el.candScore + "/40): " + el.score + "\n";
});
sendMess(message);
}
function expLoaded(data) {
var doc = (new DOMParser()).parseFromString(data.contents, "text/html");
var pars = doc.querySelectorAll("#mw-content-text p");
// In a lot of these explanations, they may not get to the point very quickly; they like to go with background info
// In these, the paragraph where the actual comic explanation typically begins with (or contains) "this comic"
// Therefore, assume it starts at the beginning unless "this comic" is found somewhere, in which case it should grab that paragraph
var parToUse = 0;
for (var i = 0; i < pars.length; i++) {
if (pars[i].textContent.match(/this\scomic/)) {
parToUse = i;
break;
}
}
try {
sendMess("> " + pars[parToUse].textContent);
} catch (e) {
try {
setTimeout(500);
// Too long; use first two sentences
var sentences = pars[parToUse].textContent.split(".");
var mess = sentences[0] + "." + sentences[1];
sendMess("> " + mess);
} catch (e) {
try {
setTimeout(500);
// Still too long? Try first paragraph
sendMess("> " + pars[0].textContent);
} catch (e) {
setTimeout(1500);
// I give up
sendMess("Explanation too long.");
}
}
}
}
function addScriptToPage(funcName) {
var script = document.createElement("script");
script.setAttribute("type", "text/javascript");
script.textContent = funcName.toString();
document.body.appendChild(script);
}
// Add script dependencies to page
addScriptToPage(sendMess);
addScriptToPage(dataLoaded);
addScriptToPage(sendAJAX);
addScriptToPage(scoresLoaded);
addScriptToPage(formatData);
addScriptToPage(expLoaded);
// Listen for chat changes
var obs = new MutationObserver(callback);
var config = {
attributes: true,
childList: true,
characterData: true
};
obs.observe(document.getElementById("chat"), config);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment