Torn Exchange Helper

TornExchange Helper Script for traders - finish trades and create trade receipts on the fly

Terá de instalar uma extensão como Tampermonkey, Greasemonkey ou Violentmonkey para instalar este script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

Terá de instalar uma extensão como Tampermonkey ou Violentmonkey para instalar este script.

Terá de instalar uma extensão como Tampermonkey ou Userscripts para instalar este script.

Terá de instalar uma extensão como Tampermonkey para instalar este script.

Terá de instalar uma extensão de gestão de scripts de utilizador para instalar este script.

(Já tenho um gestor de scripts de utilizador, deixe-me instalá-lo!)

Terá de instalar uma extensão como Stylus para instalar este estilo.

Terá de instalar uma extensão como Stylus para instalar este estilo.

Terá de instalar uma extensão como Stylus para instalar este estilo.

Terá de instalar uma extensão de gestão de estilos de utilizador para instalar este estilo.

Terá de instalar uma extensão de gestão de estilos de utilizador para instalar este estilo.

Terá de instalar uma extensão de gestão de estilos de utilizador para instalar este estilo.

(Já tenho um gestor de estilos de utilizador, deixe-me instalá-lo!)

// ==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 &#9989;</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();
  }

})();