TornExchange Helper Script for traders - finish trades and create trade receipts on the fly
// ==UserScript==
// @name Torn Exchange Helper
// @namespace te.helper
// @version 1.0.3
// @author Ata [2507441]
// @description TornExchange Helper Script for traders - finish trades and create trade receipts on the fly
// @license MIT
// @icon https://www.google.com/s2/favicons?sz=64&domain=torn.com
// @match https://www.torn.com/trade.php*
// @grant GM_addStyle
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_xmlhttpRequest
// @run-at document-end
// ==/UserScript==
(function () {
'use strict';
const CSS = `
div.te_container {
color: white !important;
width: 100%;
text-align: center;
margin-top: 10px;
margin-bottom: 10px;
background-color: #3e3e3e;
border-radius: 4px;
box-shadow: 0px 2px 4px 2px #7f7f7f;
}
table.te_table {
margin-top:10px;
text-align: left;
width: 100%;
max-width:100%;
}
table.te_table tr {
border-bottom: 1px solid #555555;
}
table.te_table th {
padding: 5px;
padding-top:8px;
padding-bottom:8px;
background-color: #373737;
}
table.te_table td {
color:white;
padding:5px !important;
}
table.te_table td input {
padding-left: 5px;
}
input.te_input {
border: 1px solid #4e4e4e;
background-color: #3e3e3e;
border-radius: 5px;
height: 20px !important;
color: white;
width:100%;
}
div.te_header {
padding-bottom: 2px;
padding-top: 4px;
border-bottom: 1px solid #535353;
border-top-left-radius: 5px;
border-top-right-radius: 5px;
background-color: #373737;
}
.te_settings_button {
position: absolute;
top: 50%;
right: 10px;
transform: translateY(-50%);
background: transparent;
border: none;
color: #a0a0a0;
font-size: 16px;
cursor: pointer;
line-height: 1;
padding: 4px;
}
.te_settings_button:hover {
color: #f7b84b;
}
div.te_wrapper {
padding:10px;
}
.te_button {
background-color: #f7b84b26;
border-radius: 5px;
padding: 6px;
color: #f7b84b;
font-weight: bold;
cursor: pointer;
padding-left: 12px;
padding-right: 12px;
border: none;
}
.te_button_dark {
background-color: #000000c2;
border-radius: 5px;
padding: 6px;
color: #f7b84b;
font-weight: bold;
cursor: pointer;
padding-left: 12px;
padding-right: 12px;
border: none;
}
.te_button:hover {
background-color: #f7b84b;
color:white;
}
.te_invalid_feedback {
width: 100%;
margin-top: .25rem;
font-size: .875em;
color: #fba189 !important;
}
.te_d_none {
display:none;
}
.te_header_image {
max-width: 100%;
height: 40px;
}
td.te_item {
text-align: left;
font-weight: bold;
}
td.te_image {
text-align: center;
border: 0px;
}
td.te_image img {
max-width: 40px;
}
td.te_profit {
color: #7CFC00;
}
.te_profit_display {
color: #7CFC00;
}
.te_total_info {
font-size: 12px;
font-weight: bold;
text-align: left;
display: inline-block;
margin: 4px 2px;
}
.te_copy_text a:link,
.te_copy_text a:visited,
.te_copy_text a:hover,
.te_copy_text a:active {
text-decoration: none;
color: #89e1fb;
cursor: pointer;
}
`;
function getApiKey() {
return GM_getValue("te_api_key", null);
}
function setApiKey(key) {
GM_setValue("te_api_key", key);
}
function clearApiKey() {
GM_setValue("te_api_key", null);
}
function showApiKeyPrompt(wrapper, onSaved, currentKey = null) {
var _a;
wrapper.innerHTML = `
<div>
<div style="display: flex; align-items: center; gap: 1rem; justify-content: center; flex-wrap: wrap;">
<label for="te_api_key_input">Enter Torn API key that you login into Torn Exchange</label>
<input type="text" id="te_api_key_input" class="te_input" style="max-width:250px;" placeholder="Torn API key" value="${currentKey ?? ""}">
<button id="te_api_key_save" class="te_button">Save</button>
${currentKey ? '<button id="te_api_key_clear" class="te_button_dark">Clear key</button>' : ""}
</div>
</div>
`;
document.getElementById("te_api_key_save").addEventListener("click", function() {
const input = document.getElementById("te_api_key_input");
const key = input.value.trim();
if (!key) {
return;
}
setApiKey(key);
onSaved();
});
(_a = document.getElementById("te_api_key_clear")) == null ? void 0 : _a.addEventListener("click", function() {
clearApiKey();
onSaved();
});
}
const ENDPOINT = "https://tornexchange.com";
const REQUEST_TIMEOUT_MS = 15e3;
function safeJsonParse(text) {
try {
return JSON.parse(text);
} catch {
return null;
}
}
function fetchPrices(items, quantities, sellerName, userName, callback) {
const data = JSON.stringify({
items,
quantities,
user_name: userName,
seller_name: sellerName
});
GM_xmlhttpRequest({
method: "POST",
url: ENDPOINT + "/new_extension_get_prices",
headers: {
"Content-Type": "application/json; charset=UTF-8"
},
data,
timeout: REQUEST_TIMEOUT_MS,
onload: function(response) {
const parsed = safeJsonParse(response.responseText);
if (!parsed) {
callback(new Error("Invalid response from server."), null);
return;
}
callback(null, parsed);
},
onerror: function(error) {
callback(error, null);
},
ontimeout: function() {
callback(new Error("Request timed out."), null);
}
});
}
function fetchReceiptByTradeId(tradeId, callback) {
GM_xmlhttpRequest({
method: "GET",
url: ENDPOINT + "/api/receipt_by_trade_id/" + tradeId + "?key=" + encodeURIComponent(getApiKey() ?? ""),
timeout: REQUEST_TIMEOUT_MS,
onload: function(response) {
if (response.status === 404) {
callback(null, null);
return;
}
if (response.status < 200 || response.status >= 300) {
callback(new Error("Request failed with status " + response.status), null);
return;
}
const parsed = safeJsonParse(response.responseText);
if (!parsed) {
callback(new Error("Invalid response from server."), null);
return;
}
callback(null, parsed);
},
onerror: function(error) {
callback(error, null);
},
ontimeout: function() {
callback(new Error("Request timed out."), null);
}
});
}
function submitReceipt(buyerName, buyerId, sellerName, itemNames, quantities, prices, tradeId, callback) {
const data = JSON.stringify({
owner_username: buyerName,
owner_user_id: buyerId,
seller_username: sellerName,
prices,
item_quantities: quantities,
item_names: itemNames,
trade_id: tradeId
});
GM_xmlhttpRequest({
method: "POST",
url: ENDPOINT + "/new_create_receipt",
headers: {
"Content-Type": "application/json; charset=UTF-8"
},
data,
timeout: REQUEST_TIMEOUT_MS,
onload: function(response) {
const parsed = safeJsonParse(response.responseText);
if (!parsed) {
callback(new Error("Invalid response from server."), null);
return;
}
callback(null, parsed);
},
onerror: function(error) {
callback(error, null);
},
ontimeout: function() {
callback(new Error("Request timed out."), null);
}
});
}
function getUsernameFromTradePage() {
var _a, _b;
let username = (_a = document.querySelector("div.user.left > div > div")) == null ? void 0 : _a.innerText;
if (username == null) {
username = (_b = document.querySelector(
"#sidebar > div:nth-child(1) > div > div > div > div > div > p > a"
)) == null ? void 0 : _b.innerText;
}
return username ?? "";
}
function getSellerNameFromTradePage() {
let sellername = document.querySelector("div.user.right > div ").innerText;
sellername = sellername.replace("Hide item values", "");
sellername = sellername.trim();
return sellername;
}
function sanitizeItemName(itemName) {
const TTregex = /\$.*/;
return itemName.replace(TTregex, "").trim().replaceAll("\n", "");
}
function getTradeItems() {
const items = [];
const quantities = [];
const regex_splitter = /\sx(?=\d{1,10})/;
const trade_elements = document.querySelectorAll(
"#trade-container > div.trade-cont.m-top10 > div.user.right > ul > li > ul > li > div.name.left"
);
for (let i = 0; i < trade_elements.length; i++) {
const el = trade_elements[i];
if (el.textContent && el.textContent.trim() !== "") {
const textContent = el.textContent.split(regex_splitter);
if (textContent.length === 2) {
items.push(sanitizeItemName(textContent[0]));
quantities.push(parseInt(sanitizeItemName(textContent[1])));
} else if (textContent.length === 1) {
const sanitized = sanitizeItemName(textContent[0]);
if (sanitized === "No items in trade") {
return null;
}
items.push(sanitized);
quantities.push(1);
}
}
}
if (quantities.length === 0) {
return null;
}
return { items, quantities };
}
function stripValue(value) {
return parseInt(String(value).replace(/\D/g, ""), 10) || 0;
}
function formatValue(value) {
return stripValue(value).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
}
function formatPrice(price) {
if (price > 1e9) {
return "$" + (price / 1e9).toFixed(3) + "b";
} else if (price > 1e6) {
return "$" + (price / 1e6).toFixed(3) + "m";
} else if (price > 1e3) {
return "$" + (price / 1e3).toFixed(3) + "k";
} else {
return "$" + price;
}
}
function htmlDecode(str) {
const doc = new DOMParser().parseFromString(str, "text/html");
return doc.documentElement.textContent ?? "";
}
function formatTemplateNumbers(inputString) {
inputString = htmlDecode(inputString);
const pattern = /\$\d+(,\d{3})*(?:\.\d+)?/g;
return inputString.replace(pattern, function(match) {
const number = match.replace(/\$|,/g, "");
return "$" + parseFloat(number).toLocaleString();
});
}
function writeToClipboard(textToCopy, callback) {
navigator.clipboard.writeText(textToCopy).then(() => {
if (callback) {
callback(null, "Text copied to clipboard successfully.");
}
}).catch((err) => {
if (callback) {
callback(err, "Failed to copy text to clipboard.");
}
});
}
let state = null;
let onSettingsClick = null;
function getWrapper() {
if (!state) {
throw new Error("te-helper UI not set up yet");
}
return state.wrapper;
}
function setSettingsHandler(handler) {
onSettingsClick = handler;
}
function setup() {
const existing = document.querySelector(".te_container");
if (existing) {
return existing;
}
const container = document.createElement("div");
container.className = "te_container";
const wrapper = document.createElement("div");
wrapper.className = "te_wrapper";
const contents = document.createElement("div");
contents.className = "te_contents";
const header = document.createElement("div");
header.className = "te_header";
header.style.position = "relative";
const teImg = document.createElement("img");
teImg.className = "te_header_image";
teImg.src = "https://tornexchange.com/static/main/images/mainlogo.png";
teImg.alt = "Header Image";
const settingsButton = document.createElement("button");
settingsButton.className = "te_settings_button";
settingsButton.title = "Update API key";
settingsButton.setAttribute("aria-label", "Settings");
settingsButton.textContent = "⚙";
settingsButton.addEventListener("click", function() {
onSettingsClick == null ? void 0 : onSettingsClick();
});
header.appendChild(teImg);
header.appendChild(settingsButton);
contents.appendChild(header);
contents.appendChild(wrapper);
container.appendChild(contents);
const anchor = document.querySelector(".info-msg-cont:not(.red)");
if (!anchor) {
return null;
}
anchor.insertAdjacentElement("afterend", container);
state = { container, contents, header, wrapper };
return container;
}
function showLoader(message = "Loading") {
getWrapper().innerHTML = `${message}`;
}
function showLookupError(message) {
getWrapper().innerHTML = `
<div>
<span class="te_invalid_feedback" role="alert">
<strong>${message}</strong>
</span>
</div>
`;
}
function createTradeRow(priceInfo) {
const row = document.createElement("tr");
const imageCell = document.createElement("td");
imageCell.className = "te_image";
const img = document.createElement("img");
img.src = priceInfo.image_url;
imageCell.appendChild(img);
row.appendChild(imageCell);
const itemCell = document.createElement("td");
itemCell.className = "te_item";
itemCell.innerText = priceInfo.item;
row.appendChild(itemCell);
const quantityCell = document.createElement("td");
quantityCell.className = "te_quantity";
quantityCell.innerText = String(priceInfo.quantity);
row.appendChild(quantityCell);
const marketPriceCell = document.createElement("td");
marketPriceCell.className = "te_market_price";
marketPriceCell.innerText = formatPrice(priceInfo.market_price);
row.appendChild(marketPriceCell);
const priceCell = document.createElement("td");
const priceInput = document.createElement("input");
priceInput.className = "te_input te_price_input";
priceInput.type = "text";
priceInput.value = formatValue(priceInfo.price);
priceCell.appendChild(priceInput);
row.appendChild(priceCell);
const profitCell = document.createElement("td");
profitCell.className = "te_profit";
row.appendChild(profitCell);
function updateProfit() {
const price = stripValue(priceInput.value);
const profit = (priceInfo.market_price - price) * priceInfo.quantity;
profitCell.innerText = formatPrice(profit);
return { price, profit };
}
updateProfit();
priceInput.addEventListener("input", function() {
priceInput.value = formatValue(priceInput.value);
updateProfit();
updateTotals();
});
row._te = {
item: priceInfo.item,
quantity: priceInfo.quantity,
getPrice: () => stripValue(priceInput.value),
updateProfit
};
return row;
}
function updateTotals() {
const table = document.querySelector(".te_table");
if (!table) return;
let totalPrice = 0;
let totalProfit = 0;
table.querySelectorAll("tbody tr").forEach((row) => {
const { price, profit } = row._te.updateProfit();
totalPrice += price * row._te.quantity;
totalProfit += profit;
});
const totalDiv = document.getElementById("te_total_info");
if (totalDiv) {
totalDiv.innerHTML = `
<span class="te_total_info">Total Price: <span class="te_profit_display">${formatPrice(totalPrice)}</span></span>
<span class="te_total_info">Total Profit: <span class="te_profit_display">${formatPrice(totalProfit)}</span></span>
`;
}
}
function renderPriceTable(priceData, buyerName, sellerName) {
const table = document.createElement("table");
table.className = "te_table";
const thead = document.createElement("thead");
thead.innerHTML = `
<tr>
<th>Image</th>
<th>Item</th>
<th>Quantity</th>
<th>Market Price</th>
<th>Price</th>
<th>Profit</th>
</tr>
`;
table.appendChild(thead);
const tbody = document.createElement("tbody");
for (let i = 0; i < priceData.items.length; i++) {
const row = createTradeRow({
item: priceData.items[i],
quantity: priceData.quantities[i],
market_price: priceData.market_prices[i],
price: priceData.prices[i],
image_url: priceData.image_url[i]
});
tbody.appendChild(row);
}
table.appendChild(tbody);
const totalDiv = document.createElement("div");
totalDiv.id = "te_total_info";
const submitButton = document.createElement("button");
submitButton.className = "te_button_dark";
submitButton.innerText = "Submit";
submitButton.style.marginTop = "10px";
submitButton.addEventListener("click", function() {
const itemNames = [];
const quantities = [];
const prices = [];
table.querySelectorAll("tbody tr").forEach((row) => {
const ctx = row._te;
itemNames.push(ctx.item);
quantities.push(ctx.quantity);
prices.push(ctx.getPrice());
});
const tradeId = getTradeId();
submitButton.disabled = true;
submitReceipt(
buyerName,
priceData.buyer_id,
sellerName,
itemNames,
quantities,
prices,
tradeId,
function(error, response) {
if (error || !response) {
showLookupError("Something went wrong submitting the receipt.");
return;
}
renderReceipt({
receipt_id: response.receipt_id,
total: response.total,
trade_message: response.trade_message,
priceData,
buyerName,
sellerName
});
}
);
});
const wrapper = getWrapper();
wrapper.innerHTML = "";
wrapper.appendChild(totalDiv);
wrapper.appendChild(table);
wrapper.appendChild(submitButton);
updateTotals();
}
function renderReceipt(receipt) {
const wrapper = getWrapper();
wrapper.innerHTML = `
<div class="response">
<h4>TE Receipt Created ✅</h4>
<p><b>Total: </b><span class="te_profit_display">${formatPrice(receipt.total)}</span></p>
</div>
`;
const receiptLink = document.createElement("a");
receiptLink.href = `https://tornexchange.com/receipt/${receipt.receipt_id}`;
receiptLink.target = "_blank";
const receiptButton = document.createElement("button");
receiptButton.className = "te_button";
receiptButton.innerText = "Receipt";
receiptButton.style.marginRight = "8px";
receiptLink.appendChild(receiptButton);
const copyTotalButton = document.createElement("button");
copyTotalButton.className = "te_button";
copyTotalButton.innerText = "Copy Total";
copyTotalButton.style.marginRight = "8px";
const resubmitButton = document.createElement("button");
resubmitButton.className = "te_button_dark";
resubmitButton.innerText = "Resubmit";
resubmitButton.addEventListener("click", function() {
renderPriceTable(receipt.priceData, receipt.buyerName, receipt.sellerName);
});
wrapper.appendChild(copyTotalButton);
wrapper.appendChild(receiptLink);
if (receipt.trade_message) {
const copyMessageButton = document.createElement("button");
copyMessageButton.className = "te_button";
copyMessageButton.innerText = "Copy Receipt Message";
copyMessageButton.style.marginRight = "8px";
wrapper.appendChild(copyMessageButton);
const responseText = formatTemplateNumbers(receipt.trade_message);
copyMessageButton.addEventListener("click", function() {
writeToClipboard(responseText, (error) => {
if (error) {
window.prompt("Copy to clipboard: Ctrl+C, Enter", responseText);
} else {
const backup = copyMessageButton.innerText;
copyMessageButton.innerText = "Copied!";
setTimeout(() => {
copyMessageButton.innerText = backup;
}, 1500);
}
});
});
}
wrapper.appendChild(resubmitButton);
const totalText = receipt.total.toString();
copyTotalButton.addEventListener("click", function() {
writeToClipboard(totalText, (error) => {
if (error) {
window.prompt("Copy to clipboard: Ctrl+C, Enter", totalText);
} else {
const backup = copyTotalButton.innerText;
copyTotalButton.innerText = "Copied!";
setTimeout(() => {
copyTotalButton.innerText = backup;
}, 1500);
}
});
});
}
function showLookupButton() {
const lookupButton = document.createElement("button");
lookupButton.className = "te_button";
lookupButton.innerText = "Lookup Prices";
const wrapper = getWrapper();
wrapper.innerHTML = "";
wrapper.appendChild(lookupButton);
lookupButton.addEventListener("click", function() {
const trade = document.getElementById("trade-container");
if (!trade) {
showLookupError("No trade found on this page.");
return;
}
const tradeItems = getTradeItems();
if (!tradeItems) {
showLookupError("No items in trade or trade already finished.");
return;
}
const userName = getUsernameFromTradePage();
const sellerName = getSellerNameFromTradePage();
showLoader("Looking up prices...");
fetchPrices(tradeItems.items, tradeItems.quantities, sellerName, userName, function(error, priceData) {
if (error || !priceData) {
console.error("Error fetching prices:", error);
showLookupError("Unable to fetch prices.");
return;
}
renderPriceTable(priceData, priceData.buyer_name, priceData.seller_name);
});
});
}
function isTradePage() {
const hash = location.hash;
return /step=(view|accept|accept2)/.test(hash);
}
function getTradeId() {
const match = location.hash.match(/ID=(\d+)/);
return match ? match[1] : null;
}
function observeElement(selector, callback = () => {
}) {
const observer = new MutationObserver((_mutations, obs) => {
let element = null;
if (selector.startsWith("#")) {
element = document.getElementById(selector.slice(1));
} else if (selector.startsWith(".")) {
element = document.querySelector(selector);
}
if (element) {
callback(element);
obs.disconnect();
}
});
observer.observe(document.body, { childList: true, subtree: true });
}
function showSettings() {
showApiKeyPrompt(getWrapper(), handleTradePage, getApiKey());
}
function handleTradePage() {
setSettingsHandler(showSettings);
if (!getApiKey()) {
showApiKeyPrompt(getWrapper(), handleTradePage);
return;
}
const tradeId = getTradeId();
if (!tradeId) {
showLookupButton();
return;
}
showLoader("Checking for an existing receipt...");
fetchReceiptByTradeId(tradeId, function(error, existingReceipt) {
if (error) {
console.error("Error checking for existing receipt:", error);
showLookupError("Could not check for an existing receipt. Click the cog icon to update your API key, or reload to try again.");
return;
}
if (existingReceipt) {
const meta = existingReceipt.meta;
const data = existingReceipt.data;
const priceData = {
items: data.items,
quantities: data.quantities,
market_prices: data.market_prices,
prices: data.prices,
image_url: data.image_url,
buyer_name: getUsernameFromTradePage(),
seller_name: meta.seller,
buyer_id: void 0
};
renderReceipt({
receipt_id: meta.receipt_id,
total: meta.total,
trade_message: meta.trade_message,
priceData,
buyerName: getUsernameFromTradePage(),
sellerName: meta.seller
});
return;
}
showLookupButton();
});
}
function handlePage() {
if (!isTradePage()) {
return;
}
observeElement(".info-msg-cont:not(.red)", function() {
const container = setup();
if (container) {
handleTradePage();
}
});
}
GM_addStyle(CSS);
if (!window.teHelperHasInitialized) {
window.teHelperHasInitialized = true;
window.addEventListener("hashchange", handlePage);
handlePage();
}
})();