Greasy Fork is available in English.

Giveaway Helper

Enhances Steam key-related giveaways

// ==UserScript==
// @name Giveaway Helper
// @namespace https://github.com/Citrinate/giveawayHelper
// @description Enhances Steam key-related giveaways
// @author Citrinate
// @version 2.12.9
// @match *://*.chubbykeys.com/giveaway.php*
// @match *://*.bananagiveaway.com/giveaway/*
// @match *://*.dogebundle.com/index.php?page=redeem&id=*
// @match *://*.dupedornot.com/giveaway*
// @match *://*.embloo.net/task/*
// @match *://*.gamecode.win/giveaway/*
// @match *://*.gamehag.com/giveaway/*
// @match *://*.gleam.io/*
// @match *://*.grabfreegame.com/giveaway/*
// @match *://*.hrkgame.com/en/giveaway/get-free-game/
// @match *://*.keychampions.net/view.php?gid=*
// @match *://*.marvelousga.com/giveaway/*
// @match *://*.prys.ga/giveaway/?id=*
// @match *://*.simplo.gg/index.php?giveaway=*
// @match *://*.steamfriends.info/free-steam-key/
// @match *://*.treasuregiveaways.com/*.php*
// @match *://*.whosgamingnow.net/giveaway/*
// @connect steamcommunity.com
// @connect steampowered.com
// @connect twitter.com
// @connect twitch.tv
// @match https://syndication.twitter.com/
// @match https://player.twitch.tv/
// @grant GM_getValue
// @grant GM.getValue
// @grant GM_setValue
// @grant GM.setValue
// @grant GM_deleteValue
// @grant GM.deleteValue
// @grant GM_addStyle
// @grant GM_xmlhttpRequest
// @grant GM.xmlHttpRequest
// @require https://ajax.googleapis.com/ajax/libs/jquery/1.11.1/jquery.min.js
// @require https://cdnjs.cloudflare.com/ajax/libs/crypto-js/3.1.2/rollups/md5.js
// @run-at document-end
// ==/UserScript==

(function() {

	/**
	 *
	 */
	var setup = (function() {
		return {
			/**
			 * Determine what to do for this page based on what's defined in the "config" variable
			 *
			 * 		hostname: A string
			 *			The hostname of the site we're setting the config for. Must be the same as what's defined
			 *			as @match in the metadata block above.
			 *
			 *		helper: An object
			 * 			The class which will determine how the do/undo buttons are added to the page. Usually this will
			 *			be set to basicHelper, which simply searches for links to Steam Groups and adds buttons for
			 *			them at the top of the page.
			 *
			 *		domMatch: An array of strings
			 *			In some cases, we don't know what page a giveaway will be on.  For example, Indiegala embeds
			 *			giveaways on various parts of their site which they want to attract attention to.  Instead we
			 *			need to search the page for a DOM element that only appears when there is a giveaway on that
			 *			page. If any of the elements in this array match, then the script will be run on this page.
			 *
			 *		urlMatch: An array of regular expressions
			 *			Used in conjunction with domMatch.  Used for pages on the domain that we do know are relevant
			 *			to giveaways, and we always want to run the script on.  For example, the giveaway confirmation
			 *			page on Indiegala.  The regular expressions will be tested against the url of the pages, and if
			 *			any of them match, the script will be run on this page.
			 *
			 *		cache: Boolean
			 *			For use with basicHelper.  Some sites will remove links to Steam groups after the entry has
			 *			been completed.  Set this to true so that any groups we find will be saved and presented later.
			 *
			 *		offset: Array of integers
			 *			For use with basicHelper.  Used to correct instances where the script's UI blocks parts of a
			 *			site.  Offsets the UI by X number of pixels in the order of [top, left, right].
			 *			Directions that shouldn't be offset should be set to 0.
			 *
			 *      zIndex: Integer
			 *          For use with basicHelper.  Used to correct instances where the site's UI might overlay the
			 *          the script's UI and will be blocked by it.
			 *
			 *		requires: An object: {twitch: Boolean}
			 *			For use with basicHelper.  Some sites may have links asking you to follow a twitch channel, but
			 *			don't verify that you've done so.  In these cases there's no need to display a "follow/unfollow"
			 *			button.  For sites that do verify, set the value to true.
			 *
			 *		redirect_urls: A function which returns a jQuery object
			 *			For use with basicHelper.  Used on sites which may hide URLs behind a redirection link.
			 *			The jQuery object should contain the anchors that contain these links, and should be specific
			 *			enough so that it only contains links we know must be resolved.
			 *
			 *		redirect_url_extract: A function which returns a string
			 *			For use with basicHelper and redirect_urls.  Used in instances where redirections are used, but
			 *			the links can't be found within anchors.  This function is used to extract the url from whatever
			 *			elements the redirect_urls function returns.
			 *
			 *		onLoad: A function
			 *			For use with basicHelper.  A function that executes after the page loads.
			 *
			 */
			run: function() {
				var found = false,
					config = [
						{
							hostname: "chubbykeys.com",
							helper: basicHelper,
							cache: false
						},
						{
							hostname: "bananagiveaway.com",
							helper: basicHelper,
							cache: true,
							redirect_urls: function() {
								return $("li:contains('Join')")
									.find("button:nth-child(1)");
							},
							redirect_url_extract: function(element) {
								return element.attr("onclick").replace("window.open('", "").replace("')", "");
							}
						},
						{
							hostname: "dogebundle.com",
							helper: basicHelper,
							cache: true,
							offset: [50, 0, 0]
						},
						{
							hostname: "dupedornot.com",
							helper: basicHelper,
							cache: false,
							requires: {twitch: true}
						},
						{
							hostname: "embloo.net",
							helper: basicHelper,
							cache: true
						},
						{
							hostname: "gamecode.win",
							helper: basicHelper,
							cache: true,
							requires: {twitch: true}
						},
						{
							hostname: "gamehag.com",
							helper: basicHelper,
							cache: true,
							offset: [80, 0, 300],
							zIndex: 80,
							redirect_urls: function() {
								return $(".element-list .task-content:contains('Steam Community group')")
									.find("a[href*='/giveaway/click/']");
							}
						},
						{
							hostname: "gleam.io",
							helper: gleamHelper,
							cache: false
						},
						{
							hostname: "grabfreegame.com",
							helper: basicHelper,
							cache: true,
							offset: [56, 0, 0],
							redirect_urls: function() {
								return $("li p:contains('Steam Group')").parent()
									.find("button:contains('To do')");
							},
							redirect_url_extract: function(element) {
								return element.attr("onclick").replace("window.open('", "").replace("')", "");
							}
						},
						{
							hostname: "hrkgame.com",
							helper: basicHelper,
							cache: false
						},
						{
							hostname: "keychampions.net",
							helper: basicHelper,
							cache: true,
							offset: [0, 120, 0]
						},
						{
							hostname: "marvelousga.com",
							helper: basicHelper,
							cache: false,
							zIndex: 1,
							requires: {twitch: true}
						},
						{
							hostname: "prys.ga",
							helper: basicHelper,
							cache: false,
							offset: [50, 0, 0],
							zIndex: 1029
						},
						{
							hostname: "simplo.gg",
							helper: basicHelper,
							cache: true
						},
						{
							hostname: "steamfriends.info",
							helper: basicHelper,
							cache: false
						},
						{
							hostname: "treasuregiveaways.com",
							helper: basicHelper,
							cache: true,
							offset: [50, 0, 0],
							zIndex: 1029
						},
						{
							hostname: "whosgamingnow.net",
							helper: basicHelper,
							cache: true
						}
					];

				for(var i = 0; i < config.length; i++) {
					var site = config[i];

					if(document.location.hostname.split(".").splice(-2).join(".") == site.hostname) {
						found = true;

						// determine whether to run the script based on the content of the page
						if(typeof site.domMatch !== "undefined" ||
							typeof site.urlMatch !== "undefined"
						) {
							var match_found = false;

							// check the DOM for matches as defined by domMatch
							if(typeof site.domMatch !== "undefined") {
								for(var k = 0; k < site.domMatch.length; k++) {
									if($(site.domMatch[k]).length !== 0) {
										match_found = true;
										break;
									}
								}
							}

							// check the URL for matches as defined by urlMatch
							if(typeof site.urlMatch !== "undefined") {
								for(var l = 0; l < site.urlMatch.length; l++) {
									var reg = new RegExp(site.urlMatch[l]);

									if(reg.test(location.href)) {
										match_found = true;
										break;
									}
								}
							}

							if(!match_found) break;
						}

						giveawayHelperUI.loadUI(site.zIndex, site.onLoad);
						site.helper.init(site.cache, site.cache_id, site.offset, site.requires, site.redirect_urls,
							site.redirect_url_extract);
					}
				}

				if(!found) {
					commandHub.init();
				}
			}
		};
	})();

	/**
	 *
	 */
	var gleamHelper = (function() {
		var gleam = null,
			authentications = { steam: false, twitter: false, twitch: false };

		/**
		 * Check to see what accounts the user has linked to gleam
		 */
		function checkAuthentications() {
			if(gleam.contestantState.contestant.authentications) {
				var authentication_data = gleam.contestantState.contestant.authentications;

				for(var i = 0; i < authentication_data.length; i++) {
					var current_authentication = authentication_data[i];
					authentications[current_authentication.provider == "twitchtv" ? "twitch" : current_authentication.provider] = current_authentication;
				}
			}
		}

		/**
		 * Decide what to do for each of the entries
		 */
		function handleEntries() {
			var entries = $(".entry-method");

			for(var i = 0; i < entries.length; i++) {
				var entry_element = entries[i],
					entry = unsafeWindow.angular.element(entry_element).scope();

				switch(entry.entry_method.entry_type) {
					case "steam_join_group":
						createSteamButton(entry, entry_element);
						break;

					case "twitter_follow":
					case "twitter_retweet":
					case "twitter_tweet":
					case "twitter_hashtags":
						//createTwitterButton(entry, entry_element);
						break;

					case "twitchtv_follow":
						createTwitchButton(entry, entry_element);
						break;

					default:
						break;
				}
			}
		}

		/**
		 *
		 */
		function handleReward() {
			var temp_interval = setInterval(function() {
				if(gleam.bestCouponCode() !== null) {
					clearInterval(temp_interval);
					SteamHandler.getInstance().findKeys(addRedeemButton, gleam.bestCouponCode(), false);
				}
			}, 100);
		}

		/**
		 * Places the button onto the page
		 */
		function addButton(entry_element) {
			return function(new_button) {
				new_button.addClass("btn btn-embossed btn-info");
				$(entry_element).find(">a").first().append(new_button);
			};
		}

		/**
		 *
		 */
		function addRedeemButton(new_button) {
			new_button.find("button").first().addClass("btn btn-embossed btn-success");
			$(".redeem-container").first().after(new_button);
		}

		/**
		 * Returns true when an entry has been completed
		 */
		function isCompleted(entry) {
			return function() {
				return gleam.isEntered(entry.entry_method) && !gleam.canEnter(entry.entry_method);
			};
		}

		/**
		 *
		 */
		function createSteamButton(entry, entry_element) {
			SteamHandler.getInstance().handleEntry({
					group_name: entry.entry_method.config3.toLowerCase(),
					group_id: entry.entry_method.config4
				},
				addButton(entry_element),
				false,
				authentications.steam === false ? false : {
					user_id: authentications.steam.uid
				}
			);
		}

		/**
		 *
		 */
		function createTwitterButton(entry, entry_element) {
			// Don't do anything for a tweet entry that's already been completed
			if(isCompleted(entry)() &&
				(entry.entry_method.entry_type == "twitter_tweet" ||
					entry.entry_method.entry_type == "twitter_hashtags")) {

				return;
			}

			TwitterHandler.getInstance().handleEntry({
					action: entry.entry_method.entry_type,
					id: entry.entry_method.config1
				},
				addButton(entry_element),
				isCompleted(entry),
				false,
				authentications.twitter === false ? false : {
					user_id: authentications.twitter.uid,
					user_handle: authentications.twitter.reference
				}
			);
		}

		/**
		 *
		 */
		function createTwitchButton(entry, entry_element) {
			TwitchHandler.getInstance().handleEntry(
				entry.entry_method.config1,
				addButton(entry_element),
				isCompleted(entry),
				false,
				authentications.twitch === false ? false : {
					user_handle: authentications.twitch.reference
				}
			);
		}

		return {
			/**
			 *
			 */
			init: function() {
				MKY.addStyle(`
					.${giveawayHelperUI.gh_button} {
						bottom: 0px;
						height: 32px;
						margin: auto;
						padding: 6px;
						position: absolute;
						right: 64px;
						top: 0px;
						z-index: 9999999999;
					}

					.${giveawayHelperUI.gh_redeem_button} {
						margin-bottom: 32px;
						position: static;
					}
				`);

				// Show exact end date when hovering over any times
				$("[data-ends]").each(function() {
					$(this).attr("title", new Date(parseInt($(this).attr("data-ends")) * 1000));
				});

				// wait for gleam to finish loading
				var temp_interval = setInterval(function() {
					if($(".popup-blocks-container") !== null) {
						clearInterval(temp_interval);
						gleam = unsafeWindow.angular.element($(".popup-blocks-container").get(0)).scope();

						// wait for gleam to fully finish loading
						var another_temp_interval = setInterval(function() {
							if(typeof gleam.campaign.entry_count !== "undefined") {
								clearInterval(another_temp_interval);
								checkAuthentications();
								handleReward();

								if(!gleam.showPromotionEnded()) {
									handleEntries();
								}
							}
						}, 100);
					}
				}, 100);
			}
		};
	})();

	/**
	 *
	 */
	var basicHelper = (function() {
		return {
			/**
			 *
			 */
			init: function(do_cache, cache_id, offset, requires, redirect_urls, redirect_url_extract) {
				if(typeof do_cache !== "undefined" && do_cache) {
					if(typeof cache_id === "undefined") {
						cache_id = document.location.hostname + document.location.pathname + document.location.search;
					}

					cache_id = `cache_${CryptoJS.MD5(cache_id)}`;
				} else {
					do_cache = false;
				}

				giveawayHelperUI.defaultButtonSetup(offset);

				// Some sites load the giveaway data dynamically.  Check every second for changes
				setInterval(function() {
					// Add Steam buttons
					SteamHandler.getInstance().findGroups(
						giveawayHelperUI.addButton,
						$("body").html(),
						true,
						do_cache,
						cache_id
					);

					// Add Steam Key redeem buttons
					SteamHandler.getInstance().findKeys(giveawayHelperUI.addButton, $("body").html(), true);

					if(typeof requires !== "undefined") {
						if(typeof requires.twitch !== "undefined" && requires.twitch === true) {
							// Add Twitch buttons
							TwitchHandler.getInstance().findChannels(
								giveawayHelperUI.addButton,
								$("body").html(),
								true,
								do_cache,
								`twitch_${cache_id}`
							);
						}

						if(typeof requires.steam_curators !== "undefined" && requires.steam_curators === true) {
							// Add Steam Curator buttons
							SteamCuratorHandler.getInstance().findCurators(
								giveawayHelperUI.addButton,
								$("body").html(),
								true,
								do_cache,
								`steam_curators_${cache_id}`
							);
						}
					}

					// Check for redirects
					if(typeof redirect_urls !== "undefined") {
						redirect_urls().each(function() {
							var redirect_url;

							if(typeof redirect_url_extract !== "undefined") {
								redirect_url = redirect_url_extract($(this));
							} else {
								redirect_url = $(this).attr("href");
							}

							giveawayHelperUI.resolveUrl(redirect_url, function(url) {
								// Add Steam button
								SteamHandler.getInstance().findGroups(
									giveawayHelperUI.addButton,
									url,
									true,
									do_cache,
									cache_id
								);

								if(typeof requires !== "undefined") {
									if(typeof requires.twitch !== "undefined" && requires.twitch === true) {
										// Add Twitch button
										TwitchHandler.getInstance().findChannels(
											giveawayHelperUI.addButton,
											url,
											true,
											do_cache,
											`twitch_${cache_id}`
										);
									}

									if(typeof requires.steam_curators !== "undefined" && requires.steam_curators === true) {
										// Steam Curator buttons
										SteamCuratorHandler.getInstance().findCurators(
											giveawayHelperUI.addButton,
											url,
											true,
											do_cache,
											`steam_curators_${cache_id}`
										);
									}
								}
							});
						});
					}
				}, 1000);
			},
		};
	})();

	/**
	 * Handles Steam group buttons
	 */
	var SteamHandler = (function() {
		function init() {
			var re_group_name = /steamcommunity\.com\/groups\/([a-zA-Z0-9\-\_]{2,32})/g,
				re_group_id = /steamcommunity.com\/gid\/(([0-9]+)|\[g:[0-9]:([0-9]+)\])/g,
				re_steam_key = /([A-Z0-9]{5}-[A-Z0-9]{5}-[A-Z0-9]{5}-[A-Z0-9]{5}-[A-Z0-9]{5}|[A-Z0-9]{5}-[A-Z0-9]{5}-[A-Z0-9]{5})/g,
				redeem_key_url = "https://store.steampowered.com/account/registerkey?key=",
				user_id = null,
				session_id = null,
				process_url = null,
				active_groups = [],
				button_count = 1,
				handled_group_names = [],
				handled_group_ids = [],
				handled_keys = [],
				ready = false;

			// Get all the user data we'll need to make join/leave group requests
			MKY.xmlHttpRequest({
				url: "https://steamcommunity.com/my/groups",
				method: "GET",
				onload: function(response) {
					user_id = response.responseText.match(/g_steamID = \"(.+?)\";/);
					session_id = response.responseText.match(/g_sessionID = \"(.+?)\";/);
					process_url = response.responseText.match(/steamcommunity.com\/(id\/.+?|profiles\/[0-9]+)\/friends\//);
					user_id = user_id === null ? null : user_id[1];
					session_id = session_id === null ? null : session_id[1];
					process_url = process_url === null ? null : "https://steamcommunity.com/" + process_url[1] + "/home_process";

					$(response.responseText).find("a[href^='https://steamcommunity.com/groups/']").each(function() {
						var group_name = $(this).attr("href").replace("https://steamcommunity.com/groups/", "");

						if(group_name.indexOf("/") == -1) {
							active_groups.push(group_name.toLowerCase());
						}
					});

					active_groups = giveawayHelperUI.removeDuplicates(active_groups);
					ready = true;
				}
			});

			function verifyLogin(expected_user) {
				if(typeof expected_user !== "undefined" && !expected_user) {
					// The user doesn't have a Steam account linked, do nothing
				} else if(user_id === null || session_id === null || process_url === null) {
					// We're not logged in
					giveawayHelperUI.showError(`You must be logged into
						<a href="https://steamcommunity.com/login" target="_blank">steamcommunity.com</a>`);
				} else if(typeof expected_user !== "undefined" && expected_user.user_id != user_id) {
					// We're logged in as the wrong user
					giveawayHelperUI.showError(`You must be logged into the linked Steam account:
						<a href="https://steamcommunity.com/profiles/${expected_user.user_id}" target="_blank">
						https://steamcommunity.com/profiles/${expected_user.user_id}</a>`);
				} else if(active_groups === null) {
					// Couldn't get user's group data
					giveawayHelperUI.showError("Unable to determine what Steam groups you're a member of");
				} else {
					return true;
				}

				return false;
			}

			/**
			 *
			 */
			function prepCreateButton(group_data, button_callback, show_name, expected_user) {
				if(typeof group_data.group_id == "undefined") {
					// Group ID is missing
					getGroupID(group_data.group_name, function(group_id) {
						group_data.group_id = group_id;
						createButton(group_data, button_callback, show_name, expected_user);
					});
				} else if(typeof group_data.group_name == "undefined") {
					// Group name is missing
					getGroupName(group_data.group_id, function(group_name) {
						group_data.group_name = group_name;

						// Fetch a separate numeric group id that we'll need
						getGroupID(group_data.group_name, function(group_id) {
							group_data.group_id = group_id;
							createButton(group_data, button_callback, show_name, expected_user);
						});
					});
				} else {
					createButton(group_data, button_callback, show_name, expected_user);
				}
			}

			/**
			 * Create a join/leave toggle button
			 */
			function createButton(group_data, button_callback, show_name, expected_user) {
				if(verifyLogin(expected_user)) {
					// Create the button
					var group_name = group_data.group_name,
						group_id = group_data.group_id,
						in_group = active_groups.indexOf(group_name) != -1,
						button_id = "steam_button_" + button_count++,
						label = in_group ?
							`Leave ${show_name ? group_name : "Group"}`
							: `Join ${show_name ? group_name : "Group"}`;

					button_callback(
						giveawayHelperUI.buildButton(button_id, label, in_group, function() {
							toggleGroupStatus(button_id, group_name, group_id, show_name);
							giveawayHelperUI.showButtonLoading(button_id);
						})
					);
				}
			}


			/**
			 * Toggle group status between "joined" and "left"
			 */
			function toggleGroupStatus(button_id, group_name, group_id, show_name) {
				var steam_community_down_error = `
					The Steam Community is experiencing issues.  Please handle any remaining Steam entries manually, or reload the page and try again.
				`;

				if(active_groups.indexOf(group_name) == -1) {
					joinSteamGroup(group_name, group_id, function(success) {
						if(success) {
							active_groups.push(group_name);
							giveawayHelperUI.toggleButtonClass(button_id);
							giveawayHelperUI.setButtonLabel(button_id, `Leave ${show_name ? group_name : "Group"}`);
						} else {
							giveawayHelperUI.showError(steam_community_down_error);
						}

						giveawayHelperUI.hideButtonLoading(button_id);
					});
				} else {
					leaveSteamGroup(group_name, group_id, function(success) {
						if(success) {
							active_groups.splice(active_groups.indexOf(group_name), 1);
							giveawayHelperUI.toggleButtonClass(button_id);
							giveawayHelperUI.setButtonLabel(button_id, `Join ${show_name ? group_name : "Group"}`);
						} else {
							giveawayHelperUI.showError(steam_community_down_error);
						}

						giveawayHelperUI.hideButtonLoading(button_id);
					});
				}
			}

			/**
			 * Join a steam group
			 */
			function joinSteamGroup(group_name, group_id, callback) {
				MKY.xmlHttpRequest({
					url: "https://steamcommunity.com/groups/" + group_name,
					method: "POST",
					headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
					data: $.param({ action: "join", sessionID: session_id }),
					onload: function(response) {
						MKY.xmlHttpRequest({
							url: "https://steamcommunity.com/my/groups",
							method: "GET",
							onload: function(response) {
								if(typeof callback == "function") {
									if($(response.responseText.toLowerCase()).find(
										`a[href='https://steamcommunity.com/groups/${group_name}']`).length === 0) {

										// Failed to join the group, Steam Community is probably down
										callback(false);
									} else {
										callback(true);
									}
								}
							}
						});
					}
				});
			}

			/**
			 * Leave a steam group
			 */
			function leaveSteamGroup(group_name, group_id, callback) {
				MKY.xmlHttpRequest({
					url: process_url,
					method: "POST",
					headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
					data: $.param({ sessionID: session_id, action: "leaveGroup", groupId: group_id }),
					onload: function(response) {
						if(typeof callback == "function") {
							if($(response.responseText.toLowerCase()).find(
								`a[href='https://steamcommunity.com/groups/${group_name}']`).length !== 0) {

								// Failed to leave the group, Steam Community is probably down
								callback(false);
							} else {
								callback(true);
							}
						}
					}
				});
			}

			/**
			 * Get the numeric ID for a Steam group
			 */
			function getGroupID(group_name, callback) {
				MKY.xmlHttpRequest({
					url: "https://steamcommunity.com/groups/" + group_name,
					method: "GET",
					headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
					onload: function(response) {
						var group_id = response.responseText.match(/OpenGroupChat\( \'([0-9]+)\'/);
						group_id = group_id === null ? null : group_id[1];

						callback(group_id);
					}
				});
			}

			/**
			 * Get the name for a Steam group given the numeric ID
			 */
			function getGroupName(group_id, callback) {
				MKY.xmlHttpRequest({
					url: "https://steamcommunity.com/gid/" + group_id,
					method: "GET",
					headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
					onload: function(response) {
						var group_name = response.finalUrl.match(/steamcommunity\.com\/groups\/([a-zA-Z0-9\-\_]{2,32})/);
						group_name = group_name === null ? null : group_name[1];

						callback(group_name.toLowerCase());
					}
				});
			}

			return {
				/**
				 *
				 */
				handleEntry: function(group_data, button_callback, show_name, expected_user) {
					if(ready) {
						prepCreateButton(group_data, button_callback, show_name, expected_user);
					} else {
						// Wait for the command hub to load
						var temp_interval = setInterval(function() {
							if(ready) {
								clearInterval(temp_interval);
								prepCreateButton(group_data, button_callback, show_name, expected_user);
							}
						}, 100);
					}
				},

				/**
				 *
				 */
				findGroups: function(button_callback, target, show_name, do_cache, cache_id) {
					var self = this;

					giveawayHelperUI.restoreCachedLinks(cache_id).then(function(group_names) {
						giveawayHelperUI.restoreCachedLinks(cache_id + "_ids").then(function(group_ids) {
							var match;

							if(!do_cache) {
								group_names = [];
								group_ids = [];
							}

							// Look for any links containing steam group names
							while((match = re_group_name.exec(target)) !== null) {
								group_names.push(match[1].toLowerCase());
							}

							// Look for any links containing steam group ids
							while((match = re_group_id.exec(target)) !== null) {
								if(typeof match[2] !== "undefined") {
									group_ids.push(match[2].toLowerCase());
								} else {
									group_ids.push(match[3].toLowerCase());
								}
							}

							group_names = giveawayHelperUI.removeDuplicates(group_names);
							group_ids = giveawayHelperUI.removeDuplicates(group_ids);

							// Cache the results
							if(do_cache) {
								giveawayHelperUI.cacheLinks(group_names, cache_id);
								giveawayHelperUI.cacheLinks(group_ids, cache_id + "_ids");
							}

							// Create the buttons
							for(var i = 0; i < group_names.length; i++) {
								if($.inArray(group_names[i], handled_group_names) == -1) {
									handled_group_names.push(group_names[i]);
									self.handleEntry({ group_name: group_names[i] }, button_callback, show_name);
								}
							}

							for(var j = 0; j < group_ids.length; j++) {
								if($.inArray(group_ids[i], handled_group_ids) == -1) {
									handled_group_ids.push(group_ids[i]);
									self.handleEntry({ group_id: group_ids[j] }, button_callback, show_name);
								}
							}
						});
					});
				},

				/**
				 *
				 */
				findKeys: function(button_callback, target, show_key) {
					var keys = [],
						match;

					while((match = re_steam_key.exec(target)) !== null) {
						keys.push(match[1]);
					}

					for(var i = 0; i < keys.length; i++) {
						if($.inArray(keys[i], handled_keys) == -1) {
							var steam_key = keys[i],
								button_id = 'redeem_' + handled_keys.length,
								label = show_key ? `Redeem ${steam_key}` : "Redeem Key",
								redeem_url = `${redeem_key_url}${steam_key}`;

							handled_keys.push(steam_key);
							button_callback(
								giveawayHelperUI.buildRedeemButton(button_id, label, redeem_url)
							);
						}
					}
				}
			};
		}

		var instance;
		return {
			getInstance: function() {
				if(!instance) instance = init();
				return instance;
			}
		};
	})();



	/**
	 * Handles Steam curator buttons
	 */
	var SteamCuratorHandler = (function() {
		function init() {
			var re_curator_id = /steampowered.com\/curator\/([0-9]+)/g,
				session_id = null,
				active_curators = [],
				button_count = 1,
				handled_curator_ids = [],
				ready = false;
				curator_ready_status = 0;

			// Get all the user data we'll need to make follow/unfollow curator requests
			MKY.xmlHttpRequest({
				url: "https://store.steampowered.com",
				method: "GET",
				onload: function(response) {
					session_id = response.responseText.match(/g_sessionID = \"(.+?)\";/);
					session_id = session_id === null ? null : session_id[1];

					MKY.xmlHttpRequest({
						url: "https://store.steampowered.com/curators/ajaxgetcurators//?query=&start=0&count=1000&filter=mycurators",
						method: "GET",
						headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
						onload: function(response) {
							var curator_urls_completed = 1;

							try {
								var data = JSON.parse(response.responseText);

								if(typeof data.success != "undefined" && typeof data.pagesize != "undefined" && typeof data.total_count != "undefined" && data.success == true) {
									parseActiveCurators(data);

									for(var i = 1; i < Math.ceil(data.total_count/data.pagesize); i++) {
										setTimeout(function(page_num) {
											if(ready) return;

											MKY.xmlHttpRequest({
												url: "https://store.steampowered.com/curators/ajaxgetcurators//?query=&start=" + (page_num * data.pagesize) + "&count=1000&filter=mycurators",
												method: "GET",
												headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
												onload: function(response) {
													try {
														var data = JSON.parse(response.responseText);

														if(typeof data.success != "undefined" && data.success == true) {
															parseActiveCurators(data);
														} else {
															ready = true;
															active_curators = null;
														}

														curator_urls_completed++;
													} catch(e) {
														ready = true;
														active_curators = null;
													}
												}
											});
										}, i * 500, i);
									}

									var temp_interval = setInterval(function() {
										if(curator_urls_completed >= Math.ceil(data.total_count/data.pagesize)) {
											clearInterval(temp_interval);
											ready = true;
										}
									}, 100)
								}
							} catch(e) {
								ready = true;
								active_curators = null;
							}
						}
					});
				}
			});

			function verifyLogin(expected_user) {
				if(typeof expected_user !== "undefined" && !expected_user) {
					// The user doesn't have a Steam account linked, do nothing
				} else if(session_id === null) {
					// We're not logged in
					giveawayHelperUI.showError(`You must be logged into
						<a href="https://steamcommunity.com/login" target="_blank">steamcommunity.com</a>`);
				}  else if(active_curators === null) {
					// Couldn't get user's group data
					giveawayHelperUI.showError("Unable to determine what Steam curators you're following");
				} else {
					return true;
				}

				return false;
			}

			/**
			 *
			 */
			function parseActiveCurators(data) {
				if(typeof data.results_html == "undefined") {
					curator_ready_status = 2;
					active_curators = null;
					return;
				}

				var re_curator_results_id = /\"clanID\":\"([0-9]+)\"/g;

				while((match = re_curator_results_id.exec(data.results_html)) !== null) {
					active_curators.push(match[1]);
				}

				return;
			}

			/**
			 * Create a join/leave Curator toggle button
			 */
			function createButton(curator_id, button_callback, show_name, expected_user) {
				if(verifyLogin(expected_user)) {
					// Create the button
					var is_following = active_curators.indexOf(curator_id) != -1,
						button_id = "steam_curator_button_" + button_count++,
						label = is_following ?
							`Unfollow ${show_name ? curator_id : "Curator"}`
							: `Follow ${show_name ? curator_id : "Curator"}`;

					button_callback(
						giveawayHelperUI.buildButton(button_id, label, is_following, function() {
							toggleCuratorStatus(button_id, curator_id, show_name);
							giveawayHelperUI.showButtonLoading(button_id);
						})
					);
				}
			}


			/**
			 * Toggle steam curator status between "following" and "not following"
			 */
			function toggleCuratorStatus(button_id, curator_id, show_name) {
				var steam_community_down_error = `
					The Steam Community is experiencing issues.  Please handle any remaining Steam entries manually, or reload the page and try again.
				`;

				if(active_curators.indexOf(curator_id) == -1) {
					followCurator(curator_id, function(success) {
						if(success) {
							active_curators.push(curator_id);
							giveawayHelperUI.toggleButtonClass(button_id);
							giveawayHelperUI.setButtonLabel(button_id, `Unfollow ${show_name ? curator_id : "Curator"}`);
						} else {
							giveawayHelperUI.showError(steam_community_down_error);
						}

						giveawayHelperUI.hideButtonLoading(button_id);
					});
				} else {
					unfollowCurator(curator_id, function(success) {
						if(success) {
							active_curators.splice(active_curators.indexOf(curator_id), 1);
							giveawayHelperUI.toggleButtonClass(button_id);
							giveawayHelperUI.setButtonLabel(button_id, `Follow ${show_name ? curator_id : "Curator"}`);
						} else {
							giveawayHelperUI.showError(steam_community_down_error);
						}

						giveawayHelperUI.hideButtonLoading(button_id);
					});
				}
			}

			/**
			 * Follow a steam curator
			 */
			function followCurator(curator_id, callback) {
				MKY.xmlHttpRequest({
					url: "https://store.steampowered.com/curators/ajaxfollow",
					method: "POST",
					data: $.param({ clanid: curator_id, sessionid: session_id, follow: "1" }),
					headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
					onload: function(response) {
						try {
							var data = JSON.parse(response.responseText);

							if(typeof data.success.success != "undefined" && data.success.success == 1) {
								callback(true);
							} else {
								callback(false);
							}
						} catch(e) {
							callback(false)
						}
					}
				});
			}

			/**
			 * Unfollow a steam curator
			 */
			function unfollowCurator(curator_id, callback) {
				MKY.xmlHttpRequest({
					url: "https://store.steampowered.com/curators/ajaxfollow",
					method: "POST",
					data: $.param({ clanid: curator_id, sessionid: session_id, follow: "0" }),
					headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
					onload: function(response) {
						try {
							var data = JSON.parse(response.responseText);

							if(typeof data.success.success != "undefined" && data.success.success == 1) {
								callback(true);
							} else {
								callback(false);
							}
						} catch(e) {
							callback(false)
						}
					}
				});
			}

			return {
				/**
				 *
				 */
				handleEntry: function(curator_id, button_callback, show_name, expected_user) {
					if(ready) {
						createButton(curator_id, button_callback, show_name, expected_user);
					} else {
						// Wait for the command hub to load
						var temp_interval = setInterval(function() {
							if(ready) {
								clearInterval(temp_interval);
								createButton(curator_id, button_callback, show_name, expected_user);
							}
						}, 100);
					}
				},

				/**
				 *
				 */
				findCurators: function(button_callback, target, show_name, do_cache, cache_id) {
					var self = this;

					giveawayHelperUI.restoreCachedLinks(cache_id).then(function(curator_ids) {
						var match;

						if(!do_cache) {
							curator_ids = [];
						}

						// Look for any links containing steam curator ids
						while((match = re_curator_id.exec(target)) !== null) {
							curator_ids.push(match[1].toLowerCase());
						}

						curator_ids = giveawayHelperUI.removeDuplicates(curator_ids);

						// Cache the results
						if(do_cache) {
							giveawayHelperUI.cacheLinks(curator_ids, cache_id);
						}

						// Create the buttons
						for(var i = 0; i < curator_ids.length; i++) {
							if($.inArray(curator_ids[i], handled_curator_ids) == -1) {
								handled_curator_ids.push(curator_ids[i]);
								self.handleEntry(curator_ids[i], button_callback, show_name);
							}
						}
					});
				}
			};
		}

		var instance;
		return {
			getInstance: function() {
				if(!instance) instance = init();
				return instance;
			}
		};
	})();

	/**
	 * Handles Twitter undo buttons
	 */
	var TwitterHandler = (function() {
		function init() {
			var command_hub_url = "https://syndication.twitter.com/",
				command_hub_host = "syndication.twitter.com",
				auth_token = null,
				csrf_token = null,
				user_handle = null,
				user_id = null,
				start_time = +new Date(),
				deleted_tweets = [], // used to make sure we dont try to delete the same (re)tweet more than once
				button_count = 1,
				ready_a = false;
				ready_b = false;

			// Get all the user data we'll need to undo twitter entries
			commandHub.load(
				command_hub_url,
				command_hub_host,
				function() {
					return {
						csrf_token: getCookie("ct0")
					};
				},
				function(data) {
					csrf_token = data.csrf_token;
					ready_a = true;
				}
			);

			MKY.xmlHttpRequest({
				url: "https://twitter.com",
				method: "GET",
				onload: function(response) {
					auth_token = $($(response.responseText)
						.find("input[id='authenticity_token']").get(0))
						.attr("value");
					user_handle = $(response.responseText)
						.find(".current-user a")
						.attr("href");
					user_id = $(response.responseText)
						.find("#current-user-id")
						.attr("value");

					auth_token = typeof auth_token == "undefined" ? null : auth_token;
					user_handle = typeof user_handle == "undefined" ? null : user_handle.replace("/", "");
					user_id = typeof user_id == "undefined" ? null : user_id;

					ready_b = true;
				}
			});

			/**
			 * Get ready to create an item
			 */
			function prepCreateButton(action_data, button_callback, ready_check, show_name, expected_user) {
				// Wait until the entry is completed before showing the button
				var temp_interval = setInterval(function() {
					if(ready_check()) {
						clearInterval(temp_interval);
						createButton(action_data, button_callback, show_name, expected_user, +new Date());
					}
				}, 100);
			}

			/**
			 * Create the button
			 */
			function createButton(action_data, button_callback, show_name, expected_user, end_time) {
				if(!expected_user) {
					// The user doesn't have a Twitter account linked, do nothing
				} else if(auth_token === null || user_handle === null || csrf_token === null) {
					// We're not logged in
					giveawayHelperUI.showError(`You must be logged into
						<a href="https://twitter.com/login" target="_blank">twitter.com</a>`);
				} else if(expected_user.user_id != user_id) {
					// We're logged in as the wrong user
					giveawayHelperUI.showError(`You must be logged into the Twitter account linked to Gleam.io:
						<a href="https://twitter.com/${expected_user.user_handle}" target="_blank">
						https://twitter.com/${expected_user.user_handle}</a>`);
				} else {
					// Create the button
					var button_id = "twitter_button_" + button_count++;

					if(action_data.action == "twitter_follow") {
						// Unfollow button
						var twitter_handle = action_data.id;

						button_callback(
							giveawayHelperUI.buildButton(button_id, `Unfollow${show_name ? ` ${twitter_handle}` : ""}`,
								false,
								function() {
									giveawayHelperUI.removeButton(button_id);

									// Get user's Twitter ID
									getTwitterUserData(twitter_handle, function(twitter_id, is_following) {
										deleteTwitterFollow(twitter_handle, twitter_id);
									});
							})
						);
					} else if(action_data.action == "twitter_retweet") {
						// Delete Retweet button
						button_callback(
							giveawayHelperUI.buildButton(button_id, "Delete Retweet", false, function() {
								giveawayHelperUI.removeButton(button_id);
								deleteTwitterRetweet(action_data.id.match(/\/([0-9]+)/)[1]);
							})
						);
					} else if(action_data.action == "twitter_tweet" || action_data.action == "twitter_hashtags") {
						// Delete Tweet button
						button_callback(
							giveawayHelperUI.buildButton(button_id, "Delete Tweet", false, function() {
								giveawayHelperUI.removeButton(button_id);

								/* We don't have an id for the tweet, so instead delete the first tweet we can find
								that was posted after we handled the entry, but before it was marked completed. */
								getTwitterTweet(end_time, function(tweet_id) {
									if(tweet_id === false) {
										giveawayHelperUI.showError(`Failed to find
											<a href="https://twitter.com/${user_handle}" target="_blank">Tweet</a>`);
									} else {
										deleteTwitterTweet(tweet_id);
									}
								});
							})
						);
					}
				}
			}

			/**
			 * @return {String} twitter_id - Twitter id for this handle
			 * @return {Boolean} is_following - True for "following", false for "not following"
			 */
			function getTwitterUserData(twitter_handle, callback) {
				MKY.xmlHttpRequest({
					url: "https://twitter.com/" + twitter_handle,
					method: "GET",
					onload: function(response) {
						var twitter_id = $($(response.responseText.toLowerCase()).find(
								`[data-screen-name='${twitter_handle.toLowerCase()}'][data-user-id]`).get(0)).attr(
								"data-user-id"),
							is_following = $($(response.responseText.toLowerCase()).find(
								`[data-screen-name='${twitter_handle.toLowerCase()}'][data-you-follow]`).get(0)).attr(
								"data-you-follow");

						if(typeof twitter_id !== "undefined" && typeof is_following !== "undefined") {
							callback(twitter_id, is_following !== "false");
						} else {
							callback(null, null);
						}
					}
				});
			}

			/**
			 * We don't have an id for the tweet, so instead delete the first tweet we can find
			 * that was posted after we handled the entry, but before it was marked completed.
			 *
			 * @param {Number} end_time - Unix timestamp in ms
			 * @return {Array|Boolean} tweet_id - The oldest (re)tweet id between start and end time, false if not found
			 */
			function getTwitterTweet(end_time, callback) {
				/* Tweets are instantly posted to our profile, but there's a delay before they're made
				public (a few seconds).  Increase the range by a few seconds to compensate. */
				end_time += (60 * 1000);

				MKY.xmlHttpRequest({
					url: "https://twitter.com/" + user_handle,
					method: "GET",
					onload: function(response) {
						var found_tweet = false,
							now = +new Date();

						// reverse the order so that we're looking at oldest to newest
						$($(response.responseText.toLowerCase()).find(
							`a[href*='${user_handle.toLowerCase()}/status/']`).get().reverse()).each(function() {

							var tweet_time = $(this).find("span").attr("data-time-ms"),
								tweet_id = $(this).attr("href").match(/\/([0-9]+)/);

							if(typeof tweet_time != "undefined" && tweet_id !== null) {
								if(deleted_tweets.indexOf(tweet_id[1]) == -1 &&
									tweet_time > start_time &&
									(tweet_time < end_time || tweet_time > now)) {

									// return the first match
									found_tweet = true;
									deleted_tweets.push(tweet_id[1]);
									callback(tweet_id[1]);
									return false;
								}
							}
						});

						// couldn't find any tweets between the two times
						if(!found_tweet) {
							callback(false);
						}
					}
				});
			}

			/**
			 * Unfollow a twitter user
			 */
			function deleteTwitterFollow(twitter_handle, twitter_id) {
				if(twitter_id === null) {
					giveawayHelperUI.showError(`Failed to unfollow Twitter user:
						<a href="https://twitter.com/${twitter_handle}" target="_blank">${twitter_handle}</a>`);
				} else {
					MKY.xmlHttpRequest({
						url: "https://api.twitter.com/1.1/friendships/destroy.json",
						method: "POST",
						headers: {
							"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
							"authorization": "Bearer AAAAAAAAAAAAAAAAAAAAAPYXBAAAAAAACLXUNDekMxqa8h%2F40K4moUkGsoc%3DTYfbDKbT3jJPCEVnMYqilB28NHfOPqkca3qaAxGfsyKCs0wRbw",
							"x-csrf-token": csrf_token,
						},
						data: $.param({ user_id: twitter_id }),
						onload: function(response) {
							if(response.status != 200) {
								giveawayHelperUI.showError(`Failed to unfollow Twitter user:
									<a href="https://twitter.com/${twitter_handle}" target="_blank">
										${twitter_handle}
									</a>`);
							}
						}
					});
				}
			}

			/**
			 * Delete a tweet
			 * @param {Array} tweet_id - A single tweet ID
			 */
			function deleteTwitterTweet(tweet_id) {
				MKY.xmlHttpRequest({
					url: "https://twitter.com/i/tweet/destroy",
					method: "POST",
					headers: {
						"Origin": "https://twitter.com",
						"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8"
					},
					data: $.param({ _method: "DELETE", authenticity_token: auth_token, id: tweet_id }),
					onload: function(response) {
						if(response.status != 200) {
							giveawayHelperUI.showError(`Failed to delete
								<a href="https://twitter.com/${user_handle}" target="_blank">Tweet}</a>`);
						}
					}
				});
			}

			/**
			 * Delete a retweet
			 * @param {Array} tweet_id - A single retweet ID
			 */
			function deleteTwitterRetweet(tweet_id) {
				MKY.xmlHttpRequest({
					url: "https://api.twitter.com/1.1/statuses/unretweet.json",
					method: "POST",
					headers: {
						"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
						"authorization": "Bearer AAAAAAAAAAAAAAAAAAAAAPYXBAAAAAAACLXUNDekMxqa8h%2F40K4moUkGsoc%3DTYfbDKbT3jJPCEVnMYqilB28NHfOPqkca3qaAxGfsyKCs0wRbw",
						"x-csrf-token": csrf_token,
					},
					data: $.param({ _method: "DELETE", id: tweet_id }),
					onload: function(response) {
						if(response.status != 200) {
							giveawayHelperUI.showError(`Failed to delete
								<a href="https://twitter.com/${user_handle}" target="_blank">Retweet</a>`);
						}
					}
				});
			}

			return {
				/**
				 *
				 */
				handleEntry: function(action_data, button_callback, ready_check, show_name, expected_user) {
					if(ready_a && ready_b) {
						prepCreateButton(action_data, button_callback, ready_check, show_name, expected_user);
					} else {
						// Wait for the command hub to load
						var temp_interval = setInterval(function() {
							if(ready_a && ready_b) {
								clearInterval(temp_interval);
								prepCreateButton(action_data, button_callback, ready_check, show_name, expected_user);
							}
						}, 100);
					}
				}
			};
		}

		var instance;
		return {
			getInstance: function() {
				if(!instance) instance = init();
				return instance;
			}
		};
	})();

	/**
	 * Handles all Twitch entries that may need to interact with Twitch
	 */
	var TwitchHandler = (function() {
		function init() {
			var command_hub_url = "https://player.twitch.tv/",
				command_hub_host = "player.twitch.tv",
				user_handle = null,
				api_token = null,
				button_count = 1,
				following_status = {},
				handled_channels = [],
				ready = false;

			// Get all the user data we'll need to undo twitch entries
			commandHub.load(
				command_hub_url,
				command_hub_host,
				function() {
					return {
						user_handle: getCookie("login"),
						api_token: getCookie("auth-token")
					};
				},
				function(data) {
					user_handle = data.user_handle;
					api_token = data.api_token;
					ready = true;
				}
			);

			/**
			 * Get ready to create an item
			 */
			function prepCreateButton(twitch_handle, button_callback, ready_check, show_name, expected_user) {
				// Wait until the entry is completed before showing the button
				var temp_interval = setInterval(function() {
					if(ready_check === null || ready_check()) {
						clearInterval(temp_interval);
						createButton(twitch_handle, button_callback, show_name, expected_user, ready_check === null);
					}
				}, 100);
			}

			/**
			 * Create the button
			 */
			function createButton(twitch_handle, button_callback, show_name, expected_user, toggle_button) {
				if(typeof expected_user !== "undefined" && !expected_user) {
					// The user doesn't have a Twitter account linked, do nothing
				} else if(user_handle === null || api_token === null) {
					// We're not logged in
					giveawayHelperUI.showError(`You must be logged into
						<a href="https://www.twitch.tv/login" target="_blank">twitch.tv</a>`);
				} else if(typeof expected_user !== "undefined" && expected_user.user_handle != user_handle) {
					// We're logged in as the wrong user
					giveawayHelperUI.showError(`You must be logged into the Twitch account linked to Gleam.io:
						<a href="https://twitch.tv/${expected_user.user_handle}" target="_blank">
						https://twitch.tv/${expected_user.user_handle}</a>`);
				} else {
					// Create the button
					var button_id = "twitch_button_" + button_count++;

					if(toggle_button) {
						getTwitchUserData(twitch_handle, function(is_following) {
							var label = is_following ? `Unfollow ${twitch_handle}` : `Follow ${twitch_handle}`;

							following_status[twitch_handle] = is_following;

							button_callback(
								giveawayHelperUI.buildButton(button_id, label, is_following, function() {
									toggleFollowStatus(button_id, twitch_handle);
									giveawayHelperUI.showButtonLoading(button_id);
								})
							);
						});
					} else {
						var label = `Unfollow${(show_name ? ` ${twitch_handle}` : "")}`;

						button_callback(
							giveawayHelperUI.buildButton(button_id, label, false, function() {
								giveawayHelperUI.removeButton(button_id);
								deleteTwitchFollow(twitch_handle);
							})
						);
					}
				}
			}

			/**
			 *
			 */
			function deleteTwitchFollow(twitch_handle, callback) {
				MKY.xmlHttpRequest({
					url: "https://api.twitch.tv/kraken/users/" + user_handle + "/follows/channels/" + twitch_handle,
					method: "DELETE",
					headers: { "Authorization": "OAuth " + api_token },
					onload: function(response) {
						if(response.status != 204 && response.status != 200) {
							giveawayHelperUI.showError(`Failed to unfollow Twitch user:
								<a href="https://twitch.tv/${twitch_handle}" target="_blank">${twitch_handle}</a>`);

							if(typeof callback == "function") callback(false);
						} else {
							if(typeof callback == "function") callback(true);
						}
					}
				});
			}

			/**
			 *
			 */
			function twitchFollow(twitch_handle, callback) {
				MKY.xmlHttpRequest({
					url: "https://api.twitch.tv/kraken/users/" + user_handle + "/follows/channels/" + twitch_handle,
					method: "PUT",
					headers: { "Authorization": "OAuth " + api_token },
					onload: function(response) {
						if(response.status != 204 && response.status != 200) {
							giveawayHelperUI.showError(`Failed to follow Twitch user:
								<a href="https://twitch.tv/${twitch_handle}" target="_blank">${twitch_handle}</a>`);

							callback(false);
						} else {
							callback(true);
						}
					}
				});
			}

			/**
			 * @return {Boolean} is_follow - True for "following", false for "not following"
			 */
			function getTwitchUserData(twitch_handle, callback) {
				MKY.xmlHttpRequest({
					url: "https://api.twitch.tv/kraken/users/" + user_handle + "/follows/channels/" + twitch_handle,
					method: "GET",
					headers: { "Authorization": "OAuth " + api_token },
					onload: function(response) {
						if(response.status === 404) {
							callback(false);
						} else if(response.status != 204 && response.status != 200) {
							giveawayHelperUI.showError(`Failed to determine follow status of Twtich user`);
						} else {
							callback(true);
						}
					}
				});
			}

			/**
			 *
			 */
			function toggleFollowStatus(button_id, twitch_handle) {
				if(following_status[twitch_handle]) {
					deleteTwitchFollow(twitch_handle, function(success) {
						if(success) {
							following_status[twitch_handle] = false;
							giveawayHelperUI.toggleButtonClass(button_id);
							giveawayHelperUI.setButtonLabel(button_id, `Follow ${twitch_handle}`);
						}

						giveawayHelperUI.hideButtonLoading(button_id);
					});
				} else {
					twitchFollow(twitch_handle, function(success) {
						if(success) {
							following_status[twitch_handle] = true;
							giveawayHelperUI.toggleButtonClass(button_id);
							giveawayHelperUI.setButtonLabel(button_id, `Unfollow ${twitch_handle}`);
						}

						giveawayHelperUI.hideButtonLoading(button_id);
					});
				}
			}

			return {
				/**
				 *
				 */
				handleEntry: function(twitch_handle, button_callback, ready_check, show_name, expected_user) {
					if(ready) {
						prepCreateButton(twitch_handle, button_callback, ready_check, show_name, expected_user);
					} else {
						// Wait for the command hub to load
						var temp_interval = setInterval(function() {
							if(ready) {
								clearInterval(temp_interval);
								prepCreateButton(twitch_handle, button_callback, ready_check, show_name, expected_user);
							}
						}, 100);
					}
				},

				/**
				 *
				 */
				findChannels: function(button_callback, target, show_name, do_cache, cache_id) {
					var self = this;

					giveawayHelperUI.restoreCachedLinks(cache_id).then(function(channels) {
						var re = /twitch\.tv\/([a-zA-Z0-9_]{2,25})/g,
							match;

						if(!do_cache) {
							channels = [];
						}

						while((match = re.exec(target)) !== null) {
							channels.push(match[1].toLowerCase());
						}

						channels = giveawayHelperUI.removeDuplicates(channels);
						if(do_cache) giveawayHelperUI.cacheLinks(channels, cache_id);

						for(var i = 0; i < channels.length; i++) {
							if(channels[i] == "login") continue;

							if($.inArray(channels[i], handled_channels) == -1) {
								handled_channels.push(channels[i]);
								self.handleEntry(channels[i], button_callback, null, show_name);
							}
						}
					});
				}
			};
		}

		var instance;
		return {
			getInstance: function() {
				if(!instance) instance = init();
				return instance;
			}
		};
	})();

    /**
     *
     */
	var giveawayHelperUI = (function() {
		var active_errors = [],
			active_buttons = {},
			gh_main_container = randomString(10),
			gh_button_container = randomString(10),
			gh_button_title = randomString(10),
			gh_button_loading = randomString(10),
			gh_spin = randomString(10),
			gh_notification_container = randomString(10),
			gh_notification = randomString(10),
			gh_error = randomString(10),
			gh_close = randomString(10),
			main_container = $("<div>", { class: gh_main_container }),
			button_container = $("<span>"),
			resolved_urls = [],
			offset_top = 0;

		/**
		 * Generate a random alphanumeric string
		 * http://stackoverflow.com/questions/10726909/random-alpha-numeric-string-in-javascript
		 */
		function randomString(length) {
			var result = '';
			var chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';

			for(var i = length; i > 0; --i) {
				result += chars[Math.floor(Math.random() * chars.length)];
			}

			return result;
		}

		/**
		 * Push the page down to make room for notifications
		 */
		function updateTopMargin() {
			var new_margin_top = main_container.outerHeight() + main_container.position().top - offset_top;

			$("html").css("margin-top", main_container.is(":visible") ? new_margin_top : 0);
		}

		return {
			gh_button: randomString(10),
			gh_button_on: randomString(10),
			gh_button_off: randomString(10),
			gh_redeem_button: randomString(10),

			/**
			 * Print the UI
			 */
			loadUI: function(zIndex, onLoad) {
				zIndex = typeof zIndex == "undefined" ? 9999999999 : zIndex;

				if(typeof onLoad == "function") onLoad();

				MKY.addStyle(`
					html {
						overflow-y: scroll !important;
					}

					.${gh_main_container} {
						font-family: "Helvetica Neue",Helvetica,Arial,sans-serif;
						font-size: 16.5px;
						left: 0px;
						line-height: 21px;
						position: fixed;
						text-align: left;
						top: 0px;
						right: 0px;
						z-index: ${zIndex};
					}

					.${gh_button_container} {
						background-color: #000;
						border-top: 1px solid rgba(52, 152, 219, .5);
						box-shadow: 0px 2px 10px rgba(0, 0, 0, .5);
						box-sizing: border-box;
						color: #3498db;
						padding: 8px;
					}

					.${gh_button_title} {
						font-family: "Helvetica Neue",Helvetica,Arial,sans-serif;
						padding: 10px 15px;
						margin-right:8px;
					}

					.${gh_button_loading} {
						-webkit-animation: ${gh_spin} 2s infinite linear;
						animation: ${gh_spin} 2s infinite linear;
						display: inline-block;
						font: normal normal normal 14px/1;
						transform-origin: 45% 55%;
					}

					.${gh_button_loading}:before {
						content: "\\21B7";
					}

					@-webkit-keyframes ${gh_spin} {
						0% {
							-webkit-transform:rotate(0deg);
							transform:rotate(0deg);
						}
						100%{
							-webkit-transform:rotate(359deg);
							transform:rotate(359deg);
						}
					}

					@keyframes ${gh_spin} {
						0% {
							-webkit-transform:rotate(0deg);
							transform:rotate(0deg);
						}
						100% {
							-webkit-transform:rotate(359deg);
							transform:rotate(359deg);
						}
					}

					.${gh_notification} {
						box-sizing: border-box;
						padding: 8px;
					}

					.${gh_error} {
						background: #f2dede;
						box-shadow: 0px 2px 10px rgba(231, 76, 60, .5);
						color: #a94442;
					}

					.${gh_error} a {
						color: #a94442;
						font-weight: 700;
					}

					.${gh_close} {
						color: #000;
						background: 0 0;
						border: 0;
						cursor: pointer;
						display: block;
						float: right;
						font-size: 21px;
						font-weight: 700;
						height: auto;
						line-height: 1;
						margin: 0px;
						opacity: .2;
						padding: 0px;
						text-shadow: 0 1px 0 #fff;
						width: auto;
					}

					.${gh_close}:hover {
						opacity: .5;
					}
				`);

				$("body").append(main_container);
			},

			/**
			 *
			 */
			defaultButtonSetup: function(offset) {
				MKY.addStyle(`
					.${this.gh_button} {
						background-image:none;
						border: 1px solid transparent;
						border-radius: 3px !important;
						cursor: pointer;
						display: inline-block;
						font-family: "Helvetica Neue",Helvetica,Arial,sans-serif;
						font-size: 12px;
						font-weight: 400;
						height: 30px;
						line-height: 1.5 !important;
						margin: 4px 8px 4px 0px;
						padding: 5px 10px;
						text-align: center;
						vertical-align: middle;
						white-space: nowrap;
					}

					.${this.gh_button}:active {
						box-shadow: inset 0 3px 5px rgba(0,0,0,.125);
						outline: 0;
					}

					.${this.gh_button_on} {
						background-color: #337ab7;
						border-color: #2e6da4;
						color: #fff;
					}

					.${this.gh_button_on}:hover {
						background-color: #286090;
						border-color: #204d74;
						color: #fff;
					}

					.${this.gh_button_off} {
						background-color: #d9534f;
						border-color: #d43f3a;
						color: #fff;
					}

					.${this.gh_button_off}:hover {
						background-color: #c9302c;
						border-color: #ac2925;
						color: #fff;
					}

					.${this.gh_redeem_button} {
						background-color: #5cb85c;
						border-color: #4cae4c;
						color: #fff;
					}
				`);

				if(typeof offset !== "undefined") {
					main_container.css({top: offset[0], left: offset[1], right: offset[2]});
					offset_top = offset[0];
				}

				main_container.append(
					$("<div>", { class: gh_button_container }).append(
						$("<span>", { class: gh_button_title }).html("Giveaway Helper v" + MKY.info.script.version)
					).append(button_container)
				);

				updateTopMargin();
			},

			/**
			 *
			 */
			addButton: function(new_button) {
				button_container.append(new_button);
				new_button.width(new_button.outerWidth());
				updateTopMargin();
			},

			/**
			 *
			 */
			buildButton: function(button_id, label, button_on, click_function) {
				var new_button =
						$("<button>", { type: "button",
							class: `${this.gh_button} ${button_on ? this.gh_button_on : this.gh_button_off}`
						}).append(
							$("<span>").append(label)).append(
							$("<span>", { class: gh_button_loading, css: { display: "none" }})
						).click(function(e) {
							e.stopPropagation();
							if(!active_buttons[button_id].find(`.${gh_button_loading}`).is(":visible")) {
								click_function();
							}
						});

				active_buttons[button_id] = new_button;
				return new_button;
			},

			/**
			 *
			 */
			buildRedeemButton: function(button_id, label, redeem_url) {
				var new_button =
						$("<a>", { href: redeem_url, target: "_blank" }).append(
							$("<button>", { type: "button",
								class: `${this.gh_button} ${this.gh_redeem_button}`
							}).append(
								$("<span>").append(label)
							)
						);

				active_buttons[button_id] = new_button;
				return new_button;
			},



			/**
			 *
			 */
			removeButton: function(button_id) {
				active_buttons[button_id].remove();
				delete active_buttons[button_id];
			},

			/**
			 *
			 */
			setButtonLabel: function(button_id, label, color) {
				active_buttons[button_id].find("span").first().text(label);

				if(color !== undefined) {
					active_buttons[button_id].css("background-color", color);
					active_buttons[button_id].css("border-color", color);
				}
			},

			/**
			 *
			 */
			toggleButtonClass: function(button_id) {
				active_buttons[button_id].toggleClass(this.gh_button_on);
				active_buttons[button_id].toggleClass(this.gh_button_off);
			},

			/**
			 *
			 */
			showButtonLoading: function(button_id) {
				active_buttons[button_id].find("span").first().hide();
				active_buttons[button_id].find(`.${gh_button_loading}`).show();
			},

			/**
			 *
			 */
			hideButtonLoading: function(button_id) {
				active_buttons[button_id].find("span").first().show();
				active_buttons[button_id].find(`.${gh_button_loading}`).hide();
			},

			/**
			 * Print an error
			 */
			showError: function(msg) {
				// Don't print the same error multiple times
				if(active_errors.indexOf(msg) != -1) return;

				var self = this;

				active_errors.push(msg);
				main_container.append(
					$("<div>", { class: `${gh_notification} ${gh_error}` }).append(
						$("<button>", { class: gh_close}).append(
							$("<span>").html("&times;")
						).click(function() {
							$(this).unbind("click");
							$(this).parent().slideUp(400, function() {
								active_errors.splice(active_errors.indexOf(msg), 1);
								$(this).remove();
								updateTopMargin();
							});
						})
					).append(
						$("<strong>").html("Giveaway Helper Error: ")
					).append(msg)
				);

				updateTopMargin();
			},

			/**
			 * Remove duplicate items from an array
			 */
			removeDuplicates: function(arr) {
				var out = [];

				for(var i = 0; i < arr.length; i++) {
					if (out.indexOf(arr[i]) == -1) {
						out.push(arr[i]);
					}
				}

				return out;
			},

			/**
			 * Some sites remove links to a group after you get your reward, remember which links we've seen where
			 */
			cacheLinks: function(data, id) {
				MKY.setValue(id, JSON.stringify(data));
			},

			/**
			 *
			 */
			restoreCachedLinks: function(id) {
				return MKY.getValue(id, JSON.stringify([])).then(function(value) {
					return JSON.parse(value);
				});
			},

			/**
			 *
			 */
			resolveUrl: function(url, callback) {
				var self = this,
					cached_url_id = `cache_${MKY.info.script.version.replace(/\./g,"_")}_${CryptoJS.MD5(url)}`;

				self.restoreCachedLinks(cached_url_id).then(function(value){
					if(value.length !== 0) {
						callback(value[0]);
					} else {
						self.cacheLinks([false], cached_url_id);

						MKY.xmlHttpRequest({
							url: url,
							method: "GET",
							onload: function(response) {
								if(response.status == 200 && response.finalUrl !== null) {
									self.cacheLinks([response.finalUrl], cached_url_id);
								}

								self.restoreCachedLinks(cached_url_id).then(function(final_url){
									callback(final_url);
								});
							}
						});
					}
				});
			}
		};
	})();

    /**
     * Used to communicate with and run code on a different domain
     * Usualy with the intent to grab necessary cookies
     */
	var commandHub = (function() {
		/**
		 * http://stackoverflow.com/a/15724300
		 */
		function getCookie(name) {
			var value = "; " + document.cookie,
				parts = value.split("; " + name + "=");

			if(parts.length == 2) {
				return parts.pop().split(";").shift();
			} else {
				return null;
			}
		}

		return {
			/**
			 * Load an iframe so that we can run code on a different domain
			 * @param {String} url - The url to be loaded into the iframe
			 * @param {Function} data_func - The code that we're going to run inside the iframe
			 * @param {Function} callback - Runs after data_func returns
			 */
			load: function(url, hostname, data_func, callback) {
				var command_hub = document.createElement('iframe');

				command_hub.style.display = "none";
				command_hub.src = url;
				document.body.appendChild(command_hub);

				hostname = hostname.replace(/\./g, "_");

				var funcvar = `command_hub_func_${hostname}`,
					retvar = `command_hub_return_${hostname}`;

				window.addEventListener("message", function(event) {
					if(event.source == command_hub.contentWindow) {
						if(event.data.status == "ready") {
							// the iframe has finished loading, tell it what to do
							MKY.setValue(funcvar, encodeURI(data_func.toString()));
							command_hub.contentWindow.postMessage({ status: "run" }, "*");
						} else if(event.data.status == "finished") {
							// wait until the values have been set
							var temp_interval = setInterval(function() {
								MKY.getValue(retvar).then(function(value) {
									if(typeof value !== "undefined") {
										clearInterval(temp_interval);

										// the iframe has finished, send the data to the callback and close the frame
										document.body.removeChild(command_hub);
										callback(value);
										MKY.deleteValue(retvar);
									}
								});
							}, 100);
						}
					}
				});
			},

			/**
			 *
			 */
			init: function() {
				var hostname = document.location.hostname.replace(/\./g, "_"),
					funcvar = `command_hub_func_${hostname}`,
					retvar = `command_hub_return_${hostname}`;

				// wait for our parent to tell us what to do
				window.addEventListener("message", function(event) {
					if(event.source == parent) {
						if(event.data.status == "run") {
							// wait until the values have been set
							var temp_interval = setInterval(function() {
								MKY.getValue(funcvar).then(function(value) {
									if(typeof value !== "undefined") {
										clearInterval(temp_interval);
										MKY.setValue(retvar, eval(`(${decodeURI(value)})`)());
										MKY.deleteValue(funcvar);
										parent.postMessage({ status: "finished" }, "*");
									}
								});
							}, 100);
						}
					}
				});

				// let the parent know the iframe is ready
				parent.postMessage({status: "ready"}, "*");
			}
		};
	})();

	// Greasemonkey 4 polyfill
	// https://arantius.com/misc/greasemonkey/imports/greasemonkey4-polyfill.js

	var MKY = typeof GM !== "undefined" ? GM : {
			'info': GM_info,
			'addStyle': GM_addStyle,
			'xmlHttpRequest': GM_xmlhttpRequest,
			'deleteValue': GM_deleteValue,
			'setValue': GM_setValue,
			'getValue': function () {
				return new Promise((resolve, reject) => {
						try {
							resolve(GM_getValue.apply(this, arguments));
						} catch (e) {
							reject(e);
						}
					});
				}
	};

	if(typeof GM_addStyle == 'undefined' || typeof MKY.addStyle == 'undefined') {
		MKY.addStyle = function(aCss) {
			'use strict';
			let head = document.getElementsByTagName('head')[0];
			if(head) {
				let style = document.createElement('style');
				style.setAttribute('type', 'text/css');
				style.textContent = aCss;
				head.appendChild(style);
				return style;
			}
			return null;
		};
	}

	setup.run();
})();