Torn Chat Banking Helper

Makes faction chat messages relating to banking withdrawals into clickable links that direct you to the give-to-user page with the requesters ID and Money ammount prefilled to help make banking requests a bit smoother. Also features a toggle that prevents withdrawls that would take the user into a negative balance.

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

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name        Torn Chat Banking Helper
// @namespace   Violentmonkey Scripts
// @match       https://www.torn.com/*
// @grant       GM_addStyle
// @version     1.11
// @author      Bilbosaggings[2323763]
// @description Makes faction chat messages relating to banking withdrawals into clickable links that direct you to the give-to-user page with the requesters ID and Money ammount prefilled to help make banking requests a bit smoother. Also features a toggle that prevents withdrawls that would take the user into a negative balance.
// @license     MIT
// ==/UserScript==

const scriptKey = 'TornChatBankingHelper';

const defaultSettings = {
  'script-allow-negative': false,
};

const log = (...args) => console.log(`[${scriptKey}]: `, ...args);

const err = (...args) => console.error(`[${scriptKey}]: `, ...args);

const waitForElement = async (selector, target = document.body) => {
  return new Promise((resolve) => {
    if (target.querySelector(selector)) {
      resolve(target.querySelector(selector));
    }

    const observer = new MutationObserver(() => {
      if (target.querySelector(selector)) {
        resolve(target.querySelector(selector));
        observer.disconnect();
      }
    });

    observer.observe(target, {
      subtree: true,
      childList: true,
    });
  });
};

let storageCache;

const clearStorage = () => {
  localStorage.removeItem(scriptKey);
  storageCache = null;
  log('Storage Cleared');
};

const getStorage = (key) => {
  const data = localStorage.getItem(scriptKey);

  const json = JSON.parse(data) ?? storageCache ?? defaultSettings;

  storageCache = json;

  return key ? json[key] : json;
};

const updateStorage = (key, value) => {
  const data = getStorage();

  data[key] = value;

  storageCache = data;

  localStorage.setItem(scriptKey, JSON.stringify(data));
};

const handleChatMessage = async (messageElement) => {
  const getValueFromBankString = (message) => {
    if (!message) return null;

    const thousandsDenominations = ['k', 'thousand', 'thousands'];

    const millionsDenominations = ['m', 'mil', 'million', 'millions'];

    const billionsDenominations = ['b', 'bn', 'billion', 'billions'];

    const moneyDenominations = [
      ...thousandsDenominations,
      ...millionsDenominations,
      ...billionsDenominations,
    ];

    const moneyDenominationsRegexSegment = moneyDenominations.join('|');

    const regex = new RegExp(
      '\\b(?:all|max(?:imum)?|full|bal(?:ance)|(?:between\\s+)?(?:[$£€]\\s*)?(?:\\d{1,3}(?:,\\d{3})+|\\d+(?:\\.\\d+)?)(?:\\s*(?:' +
        moneyDenominationsRegexSegment +
        '))?\\s*(?:-|to|–|—|and)\\s*(?:[$£€]\\s*)?(?:\\d{1,3}(?:,\\d{3})+|\\d+(?:\\.\\d+)?)(?:\\s*(?:' +
        moneyDenominationsRegexSegment +
        '))?|(?:[$£€]\\s*)?(\\d{1,3}(?:,\\d{3})+|\\d+(?:\\.\\d+)?)(?:\\s*(' +
        moneyDenominationsRegexSegment +
        '))?)\\b',
      'gi',
    );

    const matches = [...message.matchAll(regex)];

    if (!matches.length) return null;

    const targetMatch =
      matches.find((m) => moneyDenominations.includes(m[2])) ??
      matches[matches.length - 1];

    if (targetMatch[1] === undefined && targetMatch[2] === undefined)
      return 'SEND_FULL';

    const [_, value, sign] = targetMatch;

    let numericValue = parseFloat(value.replace(/,/g, ''));

    if (thousandsDenominations.includes(sign)) numericValue *= 1000;
    else if (millionsDenominations.includes(sign)) numericValue *= 1000000;
    else if (billionsDenominations.includes(sign)) numericValue *= 1000000000;

    return numericValue;
  };

  if (!messageElement) return;

  const message = await waitForElement('[class*="message"]', messageElement);

  if (!message) return;

  if (message.getAttribute(scriptKey) === 'true') return;

  message.setAttribute(scriptKey, 'true');

  const messageText = message.innerText;
  const normalised = messageText.trim().toLowerCase();

  const searchStrings = [
    'bank',
    'banke',
    'banker',
    'bankers',
    'withdraw',
    'balance',
    'vault',
  ];
  const isMessageForBanker = searchStrings.some((string) =>
    normalised.includes(string),
  );

  if (!isMessageForBanker) return;

  const moneyValue = getValueFromBankString(normalised);

  if (!moneyValue) return;

  const sender = messageElement.querySelector('[class*="sender_"]');

  const senderId = sender.href ? sender.href.split('XID=')[1] : 0;

  if (senderId === 0) return;

  const paymentLink = `https://www.torn.com/factions.php?step=your#/tab=controls&giveMoneyTo=${senderId}&money=${moneyValue}`;

  setTimeout(() => {
    message.innerHTML = `<a href="${paymentLink}" style="color:inherit">${message.innerText}</a>`;
    messageElement.style.border = '1.5px solid green';
  }, 0);
};

const processCurrentMessages = async (chatElement) => {
  const messages = await waitForElement('[class^="list"]', chatElement);

  if (!messages) return;

  const children = messages.children;

  if (!children) return;

  for (let i = 0; i < children.length; i++) handleChatMessage(children[i]);
};

const watchForNewMessages = async (chatElement) => {
  // const messages = chatElement.querySelector('[class^="list"]');
  const messages = await waitForElement('[class^="list"]', chatElement);

  const observer = new MutationObserver(() => {
    processCurrentMessages(chatElement);
  });

  observer.observe(messages, {
    childList: true,
    subtree: true,
  });

  return observer;
};

const handleGiveToUserPage = async () => {
  const urlParams = new URLSearchParams(
    window.location.hash.split('?')[1] ??
      window.location.hash.replace(/^#\/?/, ''),
  );

  if (
    !window.location.pathname.includes('factions.php') ||
    urlParams.get('tab') !== 'controls'
  )
    return;

  const getUsersBalance = async () => {
    const usersBalance = await waitForElement(
      '[class*="form"] p span[class*="nowrap"]',
    );

    const extractBalance = () => {
      const text = usersBalance.textContent;

      const match = text.match(/\$\s*([\d,]+)/);

      return match ? parseInt(match[1].replace(/,/g, ''), 10) : null;
    };

    const balance = extractBalance();

    if (balance !== null) return balance;

    return new Promise((resolve) => {
      const observer = new MutationObserver(() => {
        const newBalance = extractBalance();

        if (newBalance !== null){
          observer.disconnect();

          resolve(newBalance);
        }
      });

      observer.observe(usersBalance, {
        childList: true,
        subtree: true,
        characterData: true,
      });
    });
  };

  const createNegativeBalancesOption = () => {
    const container = document.createElement('div');
    container.id = `${scriptKey}-container-script-allow-negative`;
    container.className = 'choice-container';
    container.style.width = '100%';

    let checkedState = getStorage('script-allow-negative');

    const input = document.createElement('input');
    input.type = 'checkbox';
    input.id = 'script-allow-negative';
    input.className = 'radio-css dark-bg';
    input.checked = checkedState;

    input.onclick = async (e) => {
      checkedState = !checkedState;

      updateStorage('script-allow-negative', checkedState);

      const giveToUserButton = await waitForElement('[class*="ctaButton"]');

      if (!checkedState) {
        if (
          giveToUserButton.classList.contains(
            `${scriptKey}-disallow-negative-balances`,
          )
        )
          giveToUserButton.disabled = true;
        return;
      }

      giveToUserButton.disabled = false;
    };

    const label = document.createElement('label');
    label.className = 'marker-css';
    label.htmlFor = input.id;
    label.textContent = 'Allow Negative Balances';

    container.appendChild(input);
    container.appendChild(label);

    return container;
  };

  const insertNegativeBalancesOption = async () => {
    const radios = await waitForElement('[role="radiogroup"]');

    const element = createNegativeBalancesOption();

    if (radios.querySelector(`#${element.id}`)) return;

    radios.insertAdjacentElement('afterend', element);
  };

  const handleRedirect = async () => {
    const requestedAmmount = urlParams.get('money');

    if (!requestedAmmount) return;

    try {
      const balance = await getUsersBalance() ?? 0;

      const giveToUserButton = await waitForElement('[class*="ctaButton"]');
      giveToUserButton.disabled = false;

      const giveToUserInput = await waitForElement(
        'input.input-money:not([type="hidden"])',
      );

      if (requestedAmmount > balance || requestedAmmount === 'SEND_FULL')
        giveToUserInput.value = balance;
      else
        giveToUserInput.value = requestedAmmount;

      giveToUserInput.dispatchEvent(new Event('input', { bubbles: true }));
    } catch (e) {
      err(`Error Updating Give-To-User Input.\n`, e);
    }
  };

  const interceptMoneySend = () => {
    document.addEventListener(
      'input',
      async (e) => {
        const target = e.target;

        if (!target?.matches('input.input-money:not([type="hidden"])')) return;

        const value = parseInt(target.value.replace(/,/g, ''), 10);

        if (!value || isNaN(value) || value === 0) return;

        const balance = await getUsersBalance();

        const giveToUserButton = await waitForElement('[class*="ctaButton"]');

        if (value < balance) {
          giveToUserButton.disabled = false;

          giveToUserButton.classList.remove(
            `${scriptKey}-disallow-negative-balances`,
          );

          return;
        } else {
          giveToUserButton.classList.add(
            `${scriptKey}-disallow-negative-balances`,
          );

          if (!getStorage('script-allow-negative'))
            giveToUserButton.disabled = true;
        }
      },
      true,
    );
  };

  try {
    log('Handling Redirect');
    await handleRedirect();
  } catch (e) {
    err('Error Handling Redirect. Error: ', e);
  }

  try {
    log('Inserting Negative Balances Option');
    await insertNegativeBalancesOption();
  } catch (e) {
    err('Error Adding Negative Balances Option. Error: ', e);
  }

  try {
    log('Intercepting Money Sends');
    interceptMoneySend();
  } catch (e) {
    err('Error Setting Intercept Money Sends. Error: ', e);
  }
};

(() => {
  let factionChat = null;
  let factionChatObserver = null;

  const detach = () => {
    if (
      !factionChatObserver ||
      typeof factionChatObserver.disconnect !== 'function'
    )
      return; //Pda fix

    factionChatObserver.disconnect();
    factionChatObserver = null;
    log('Disconnected Message Observer');
  };

  const chatObserver = new MutationObserver(() => {
    const chat = document.querySelector('div[class^="root"][id^="faction-"]');

    if (chat && !factionChat) {
      log('Chat is now visible');
      factionChat = chat;

      try {
        log('Processing Current Messages');
        processCurrentMessages(factionChat);
      } catch (error) {
        err('Error Processing Current Messages: ', error);
      } finally {
        log('Finished Processing Current Messages');
      }

      if (factionChatObserver) detach();

      try {
        log('Watching For New Messages In Faction Chat');
        factionChatObserver = watchForNewMessages(factionChat);
      } catch (error) {
        err('Error Watching For New Messages: ', error);
      }
    }
    if (!chat && factionChat) {
      log('Chat is not visible');
      factionChat = null;
      detach();
    }
  });

  chatObserver.observe(document.body, {
    subtree: true,
    childList: true,
  });

  handleGiveToUserPage();
})();

GM_addStyle(`
  .${scriptKey}-disallow-negative-balances {
    --btn-background: linear-gradient(
      180deg,
      #a42a2a 0%,
      #b03030 25%,
      #8f2323 60%,
      #7c1d1d 78%,
      #6b1717 100%
    ) !important;
    --btn-border: 1px solid #5a1414 !important;
    --btn-color: #ffffff !important;
    --btn-text-shadow: 0 1px 0 rgba(0, 0, 0, 0.35) !important;

    background: var(--btn-background) !important;
    border: var(--btn-border) !important;
    color: var(--btn-color) !important;
    text-shadow: var(--btn-text-shadow) !important;

    cursor: pointer !important;
    transition: background 0.15s ease, filter 0.15s ease;
  }

  .${scriptKey}-disallow-negative-balances:hover:not(:disabled):not([aria-disabled="true"]) {
    --btn-background: linear-gradient(
      180deg,
      #b03030 0%,
      #c03535 25%,
      #9b2626 60%,
      #8a2020 78%,
      #781a1a 100%
    ) !important;
    background: var(--btn-background) !important;
    filter: brightness(1.1);
  }

  .${scriptKey}-disallow-negative-balances:active:not(:disabled):not([aria-disabled="true"]) {
    --btn-background: linear-gradient(
      180deg,
      #7c1d1d 0%,
      #8a2020 25%,
      #6b1717 60%,
      #5a1414 78%,
      #4a1010 100%
    ) !important;
    background: var(--btn-background) !important;
    filter: brightness(0.9);
  }

  .${scriptKey}-disallow-negative-balances:disabled,
  .${scriptKey}-disallow-negative-balances[aria-disabled="true"] {
    --btn-background: linear-gradient(
      180deg,
      #5f2a2a 0%,
      #6b2f2f 25%,
      #532222 60%,
      #471c1c 78%,
      #3d1717 100%
    ) !important;
    --btn-border: 1px solid #3a1212 !important;
    --btn-color: #e0bcbc !important;
    --btn-text-shadow: none !important;

    background: var(--btn-background) !important;
    border: var(--btn-border) !important;
    color: var(--btn-color) !important;

    cursor: not-allowed !important;
    opacity: 0.9 !important;
  }
`);