diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..937e3aa --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +__pycache__ +.vscode +config.json +logs +include +lib +Scripts +bin +pyvenv.cfg +lib64 +*.log +*.bat \ No newline at end of file diff --git a/LICENSE b/LICENSE index 7545121..39e0d9a 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2020 offish +Copyright (c) 2020-2023 offish Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 8ae1e31..74f8ec9 100644 --- a/README.md +++ b/README.md @@ -6,93 +6,154 @@ [![Discord](https://img.shields.io/discord/467040686982692865?color=7289da&label=Discord&logo=discord)](https://discord.gg/t8nHSvA) [![Code style](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) -[![Donate Steam](https://img.shields.io/badge/donate-steam-green.svg)](https://steamcommunity.com/tradeoffer/new/?partner=293059984&token=0-l_idZR) -[![Donate PayPal](https://img.shields.io/badge/donate-paypal-blue.svg)](https://www.paypal.me/0ffish) +Automated TF2 trading bot with GUI support, built with Python. Prices are by default provided by [Prices.TF](https://prices.tf). + +## Donate +Donations are not required, but greatly appericated. +- BTC: `bc1qntlxs7v76j0zpgkwm62f6z0spsvyezhcmsp0z2` +- [Steam Trade Offer](https://steamcommunity.com/tradeoffer/new/?partner=293059984&token=0-l_idZR) -Automated trading bot for Team Fortress 2 using prices provided by [Prices.TF](https://prices.tf). ## Features -* Automatic pricing from [Prices.TF](https://prices.tf) -* Basic website GUI (going to be updated) -* Uses MongoDB for saving/getting prices and trades -* Support for running multiple bots at once -* Accepts offer(s) sent by owner -* Supports both Non-Craftable and Craftable items -* Supports Random Craft Hats -* Supports options listed in [`settings.py`](express/settings.py) -* Saves trade data after trade has gone through -* Colored and readable logging +* Automated item pricing by [Prices.TF](https://prices.tf) +* GUI for adding items, changing prices and browsing trades * Bank as many items as you want - -Backpack.tf listing might be added in the future. - -## Screenshots -![GUI](https://user-images.githubusercontent.com/30203217/120229592-c2b76000-c24d-11eb-8d23-725556925ba3.png) -![Screenshot](https://user-images.githubusercontent.com/30203217/99878862-a2587a00-2c08-11eb-9211-8c8ac86821e6.png) +* Add items by name or SKU +* Uses MongoDB for saving items, prices and trades +* Supports Random Craft Hats [[?]](#random-craft-hats) +* Run multiple bots at once, each with their own database +* Supports SKU item formats for ease of use +* Supports 3rd party inventory providers [[?]](#3rd-party-inventory-providers) +* Utilizes [tf2-sku](https://github.com/offish/tf2-sku) +* Utilizes [tf2-data](https://github.com/offish/tf2-data) +* Utilizes [tf2-utils](https://github.com/offish/tf2-utils) + +*Backpack.tf listing is not supported yet.* + +## Showcase +![GUI Prices](https://github.com/offish/tf2-express/assets/30203217/31dbe594-877b-486c-b06e-a243bcbce34c) +![GUI Trades](https://github.com/offish/tf2-express/assets/30203217/7a5225a8-cbd6-4e12-b703-1bf9e0d0d674) +![tf2-express](https://github.com/offish/tf2-express/assets/30203217/c32d6c2e-b59d-4923-97e7-8ba7cf5f8640) ## Installation -Download the repository, navigate to the folder, and install the required packages. +Full installation guide can be found on the [wiki](https://github.com/offish/tf2-express/wiki). -``` -pip install -r requirements.txt +If MongoDB is already installed, it should be fairly straight forward. + +```bash +git clone git@github.com:offish/tf2-express.git +cd tf2-express +pip install -r requirements.txt ``` ## Setup -Configure the `bots` variable inside the [`config.py`](express/config.py) file. Here you need to add your bots credentials. +Rename `config.example.json` to `config.json`. Update credentials and set your preferred `options`. +Example config: ```json { - "name": "Bot 1", - "username": "steam-username", - "password": "steam-password", - "api_key": "api-key", - "secrets": { - "steamid": "steam-id-64", - "shared_secret": "sharedsecret=", - "identity_secret": "identitysecret=" - } -}, -{ - "name": "Bot 2", - "username": "steam-username", - "password": "steam-password", - "api_key": "api-key", - "secrets": { - "steamid": "steam-id-64", - "shared_secret": "sharedsecret=", - "identity_secret": "identitysecret=" - } + "name": "nickname", + "check_versions_on_startup": true, + "bots": [ + { + "name": "bot1", + "username": "username", + "password": "password", + "api_key": "111AA1111AAAA11A1A11AA1AA1AAA111", + "secrets": { + "steamid": "76511111111111111", + "shared_secret": "Aa11aA1+1aa1aAa1a=", + "identity_secret": "aA11aaaa/aa11a/aAAa1a1=" + }, + "options": { + "accept_donations": true, + "decline_bad_offers": false, + "decline_trade_hold": true, + "decline_scam_offers": true, + "allow_craft_hats": true, + "save_trades": true, + "poll_interval": 30, + "owners": [ + "76511111111111111", + "76522222222222222" + ] + } + }, + { + "name": "bot2", + "username": "username2", + "password": "password2", + "api_key": "111AA1111AAAA11A1A11AA1AA1AAA111", + "secrets": { + "steamid": "76511111111111111", + "shared_secret": "Aa11aA1+1aa1aAa1a=", + "identity_secret": "aA11aaaa/aa11a/aAAa1a1=" + }, + "options": { + "accept_donations": true, + "decline_bad_offers": false, + "decline_trade_hold": false, + "decline_scam_offers": false, + "allow_craft_hats": false, + "save_trades": true, + "poll_interval": 60, + "database": "bot2database" + } + } + ] } ``` -If you're running multiple bots, the variable should look something like this. `Name` is only for logging, this could be whatever you want (username, index, symbol, etc). -You can also change your settings inside the [`settings.py`](express/settings.py) file. -Every option/setting here should be pretty self explanatory. +For more information follow the [wiki](https://github.com/offish/tf2-express/wiki). -```python -accept_donations = True -decline_trade_hold = True -decline_scam_offers = True -allow_craft_hats = True -save_trades = True +## Running +```bash +# tf2-express/ +python main.py # start the bot +python panel.py # start the gui ``` +After starting the GUI, you can open http://127.0.0.1:5000/ in your browser. -## Running -After you have configured the bot you can run this command. Make sure you're in the correct directory. -``` -python main.py -``` +Logs will be available under `logs/express.log`. +Level is set to DEBUG, so here you will be able to see every request etc. and more information than is shown in the terminal. + +*Do NOT share this log file with anyone else before removing sensitive information. This will leak your `API_KEY` and more.* -To open the GUI run this command while being in the same directory as the [`main.py`](main.py) file, and open http://127.0.0.1:5000 in your browser. +## Updating +```bash +# tf2-express/ +git pull +pip install --upgrade -r requirements.txt +# update packages like tf2-utils, tf2-data and tf2-sku, +# which the bot is dependant on ``` -python -m express.ui.panel + +## Explanation +### Random Craft Hats +If a craftable hat does not have a specific price in the database, it will be viewed as a Random Craft Hat (SKU: -100;6), if `enable_craft_hats` is enabled. + +**WARNING:** *This applies to any hat. Such as Ellis' Cap, Team Captain, Earbuds, Max Heads etc. This is a feature, not a bug.* + +Simply open the GUI and add "Random Craft Hat" to the pricelist. Set the buy and sell price to whatever you want. This item cannot get automatic price updates. + +### 3rd Party Inventory Providers +Avoid Steam's inventory rate-limits by using a third party provider like SteamApis, Steam.supply or your own. + +## Testing +```bash +# tf2-express/ +python -m unittest ``` +## Todo +- [ ] Add stock limits (in stock/max stock) +- [ ] Add BackpackTF listing + ## License MIT License -Copyright (c) 2020 [offish](https://offi.sh) +Copyright (c) 2020-2023 offish Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/express/__init__.py b/express/__init__.py index a29d7ec..837ed85 100644 --- a/express/__init__.py +++ b/express/__init__.py @@ -1,4 +1,4 @@ -__title__ = "tf2-express" -__author__ = "offish" -__license__ = "MIT" -__version__ = "1.2.9" +__title__ = "tf2-express" +__author__ = "offish" +__license__ = "MIT" +__version__ = "2.0.0" diff --git a/express/client.py b/express/client.py deleted file mode 100644 index cc76d91..0000000 --- a/express/client.py +++ /dev/null @@ -1,58 +0,0 @@ -from json import dumps - -from .logging import Log, f - -from steampy.client import SteamClient -from steampy.exceptions import InvalidCredentials - - -class Client: - def __init__(self, bot: dict): - self.log = Log(bot["name"]) - self.username = bot["username"] - self.password = bot["password"] - self.secrets = bot["secrets"] - self.client = SteamClient(bot["api_key"]) - - def login(self) -> None: - try: - self.client.login(self.username, self.password, dumps(self.secrets)) - - if self.client.was_login_executed: - self.log.info(f"Logged into Steam as {f.GREEN + self.username}") - else: - self.log.error("Login was not executed") - - except InvalidCredentials as e: - self.log.error(e) - - def logout(self) -> None: - self.log.info("Logging out...") - self.client.logout() - - def get_offers(self) -> dict: - try: - return self.client.get_trade_offers(merge=True)["response"][ - "trade_offers_received" - ] - except KeyError: - return {} - - def get_offer(self, offer_id: str) -> dict: - try: - return self.client.get_trade_offer(offer_id, merge=True)["response"][ - "offer" - ] - except KeyError: - return {} - - def get_receipt(self, trade_id: str) -> dict: - return self.client.get_trade_receipt(trade_id) - - def accept(self, offer_id: str) -> None: - self.log.trade("Trying to accept offer", offer_id) - self.client.accept_trade_offer(offer_id) - - def decline(self, offer_id: str) -> None: - self.log.trade("Trying to decline offer", offer_id) - self.client.decline_trade_offer(offer_id) diff --git a/express/config.example.json b/express/config.example.json new file mode 100644 index 0000000..2dbb820 --- /dev/null +++ b/express/config.example.json @@ -0,0 +1,51 @@ +{ + "name": "nickname", + "check_versions_on_startup": true, + "bots": [ + { + "name": "bot1", + "username": "username", + "password": "password", + "api_key": "111AA1111AAAA11A1A11AA1AA1AAA111", + "secrets": { + "steamid": "76511111111111111", + "shared_secret": "Aa11aA1+1aa1aAa1a=", + "identity_secret": "aA11aaaa/aa11a/aAAa1a1=" + }, + "options": { + "accept_donations": true, + "decline_bad_offers": false, + "decline_trade_hold": true, + "decline_scam_offers": true, + "allow_craft_hats": true, + "save_trades": true, + "poll_interval": 30, + "owners": [ + "76511111111111111", + "76522222222222222" + ] + } + }, + { + "name": "bot2", + "username": "username2", + "password": "password2", + "api_key": "111AA1111AAAA11A1A11AA1AA1AAA111", + "secrets": { + "steamid": "76511111111111111", + "shared_secret": "Aa11aA1+1aa1aAa1a=", + "identity_secret": "aA11aaaa/aa11a/aAAa1a1=" + }, + "options": { + "accept_donations": true, + "decline_bad_offers": false, + "decline_trade_hold": false, + "decline_scam_offers": false, + "allow_craft_hats": false, + "save_trades": true, + "poll_interval": 60, + "database": "bot2database" + } + } + ] +} \ No newline at end of file diff --git a/express/config.py b/express/config.py deleted file mode 100644 index a4c9452..0000000 --- a/express/config.py +++ /dev/null @@ -1,28 +0,0 @@ -BOTS = [ - { - "name": "bot1", - "username": "username", - "password": "password", - "api_key": "111AA1111AAAA11A1A11AA1AA1AAA111", - "secrets": { - "steamid": "76511111111111111", - "shared_secret": "Aa11aA1+1aa1aAa1a=", - "identity_secret": "aA11aaaa/aa11a/aAAa1a1=", - }, - }, - { - "name": "bot2", - "username": "username", - "password": "password", - "api_key": "111AA1111AAAA11A1A11AA1AA1AAA111", - "secrets": { - "steamid": "76511111111111111", - "shared_secret": "Aa11aA1+1aa1aAa1a=", - "identity_secret": "aA11aaaa/aa11a/aAAa1a1=", - }, - }, -] - -OWNERS = ["76511111111111111"] -TIMEOUT = 30 -DEBUG = True diff --git a/express/database.py b/express/database.py index a983f98..f128f98 100644 --- a/express/database.py +++ b/express/database.py @@ -1,58 +1,115 @@ -from .logging import Log - -from pymongo import MongoClient - - -client = MongoClient("localhost", 27017) -database = client["express"] - -trades = database["trades"] -prices = database["prices"] - -log = Log() - - -def add_trade(data: dict) -> None: - trades.insert(data) - log.info(f"Offer was added to the database") - - -def get_trades() -> dict: - return trades.find() - - -def get_items() -> list: - return [item["name"] for item in prices.find()] - - -def get_autopriced_items() -> list: - return [item["name"] for item in prices.find() if item.get("autoprice")] - - -def get_item(name: str) -> dict: - return prices.find_one({"name": name}) - - -def _get_price(name: str) -> dict: - return prices.find_one({"name": name}) - - -def get_database_pricelist() -> dict: - return prices.find() - - -def add_price(name: str) -> None: - prices.insert({"name": name, "autoprice": True, "buy": None, "sell": None}) - log.info(f"Added {name} to the database") - - -def update_price(name: str, autoprice: bool, buy: dict, sell: dict) -> None: - prices.replace_one( - {"name": name}, {"name": name, "autoprice": autoprice, "buy": buy, "sell": sell} - ) - log.info(f"Updated price for {name}") - - -def remove_price(name: str) -> None: - prices.delete_one({"name": name}) - log.info(f"Removed {name} from the database") +import logging +import time + +from tf2_utils import refinedify +from pymongo import MongoClient + + +class Database: + def __init__(self, name: str, host: str = "localhost", port: int = 27017) -> None: + client = MongoClient(host, port) + db = client[name] + + self.trades = db["trades"] + self.items = db["items"] + + @staticmethod + def has_price(data: dict) -> bool: + return data.get("buy", {}) != {} and data.get("sell", {}) != {} + + def insert_trade(self, data: dict) -> None: + logging.debug("Adding new trade to database") + self.trades.insert_one(data) + + def get_trades( + self, start_index: int, amount: int + ) -> tuple[list[dict], int, int, int]: + all_trades = list(self.trades.find()) + total = len(all_trades) + intended_end_index = start_index + amount + result = all_trades[start_index:intended_end_index] + actual_end_index = start_index + len(result) + + return (result, total, start_index, actual_end_index) + + def __get_data(self, sku: str) -> dict | None: + return self.items.find_one({"sku": sku}) + + def get_price(self, sku: str, intent: str) -> tuple[int, float]: + item_price = self.__get_data(sku) + + # item does not exist in db or does not have a price + if item_price is None or not self.has_price(item_price): + return (0, 0.0) + + price = item_price[intent] + + keys = price.get("keys", 0) + metal = price.get("metal", 0.0) + + return (keys, metal) + + def get_skus(self) -> list[str]: + return [item["sku"] for item in self.items.find()] + + def get_autopriced(self) -> list[str]: + return [ + item["sku"] + for item in self.items.find({"autoprice": True}) + if item["sku"] != "-100;6" + ] + + def get_item(self, sku: str) -> dict | None: + return self.items.find_one({"sku": sku}) + + def get_pricelist(self) -> list[dict]: + return self.items.find() + + def add_price(self, sku: str, color: str, image: str, name: str) -> None: + self.items.insert_one( + { + "sku": sku, + "name": name, + "color": color, + "image": image, + "autoprice": True, # default to autoprice + "buy": {}, + "sell": {}, + } + ) + logging.info(f"Added {sku} to the database") + + def update_price( + self, sku: str, buy: dict, sell: dict, autoprice: bool = False + ) -> None: + data = self.__get_data(sku) + + data["buy"] = buy + data["sell"] = sell + data["autoprice"] = autoprice + data["updated"] = time.time() + + self.items.replace_one( + {"sku": sku}, + data, + ) + logging.info(f"Updated price for {sku}") + + def update_autoprice(self, data: dict) -> None: + sku = data["sku"] + buy_keys = data.get("buyKeys", 0) + # will have many decimals e.g. 2.2222223 if we dont refinedify + buy_metal = refinedify(data.get("buyHalfScrap", 0.0) / 18) + sell_keys = data.get("sellKeys", 0) + sell_metal = refinedify(data.get("sellHalfScrap", 0.0) / 18) + + self.update_price( + sku, + {"metal": buy_metal, "keys": buy_keys}, + {"metal": sell_metal, "keys": sell_keys}, + True, + ) + + def delete_price(self, sku: str) -> None: + self.items.delete_one({"sku": sku}) + logging.info(f"Removed {sku} from the database") diff --git a/express/express.py b/express/express.py new file mode 100644 index 0000000..d2fc55d --- /dev/null +++ b/express/express.py @@ -0,0 +1,327 @@ +from .database import Database +from .options import Options +from .offer import valuate + +from json import dumps +import logging +import time + +from steampy.exceptions import InvalidCredentials +from steampy.client import SteamClient +from tf2_utils import Offer, to_refined, refinedify, PricesTF + + +class Express: + def __init__(self, bot: dict, options: Options) -> None: + self.name = bot["name"] + self.username = bot["username"] + self.password = bot["password"] + self.secrets = bot["secrets"] + self.steam_id = self.secrets["steamid"] + self.client = SteamClient(bot["api_key"]) + self.db = Database(options.database) + self.pricer = PricesTF() + + # TODO: make processed and values into offers list[Offer] + self.values = {} + self.processed = [] + self.last_offer_fetch = 0 + self.options = options + + self.is_enabled = True + # TODO: add processed_offers, accepted_offers, active_offers for status + + self.prices_to_check = [] + + self.last_logged_offer = "" + + def login(self) -> None: + logging.debug("Trying to login to Steam") + + try: + self.client.login(self.username, self.password, dumps(self.secrets)) + + if not self.client.was_login_executed: + logging.critical("Login was not executed") + return + + logging.info(f"Logged into Steam as {self.username}") + + except InvalidCredentials as e: + logging.error(e) + + def logout(self) -> None: + logging.info("Logging out...") + self.client.logout() + + def append_new_price(self, data: dict) -> None: + self.prices_to_check.append(data) + + def __log_trade( + self, message: str, offer_id: str = "", level: str = "INFO" + ) -> None: + if offer_id and offer_id != self.last_logged_offer: + self.last_logged_offer = offer_id + + if not offer_id: + offer_id = self.last_logged_offer + + numeric_level = logging.getLevelName(level) + logging.log(numeric_level, f"({offer_id}) {message}") + + def __update_prices(self) -> None: + skus = self.db.get_autopriced() + + for price in self.prices_to_check.copy(): + self.prices_to_check.remove(price) + + if price["sku"] not in skus: + logging.debug(f"SKU: {price['sku']} does not exist in our pricelist") + continue + + self.db.update_autoprice(price) + + def __get_offers(self) -> list[dict]: + logging.info("Fetching offers...") + self.last_offer_fetch = time.time() + + try: + return self.client.get_trade_offers(merge=True)["response"][ + "trade_offers_received" + ] + except Exception as e: + logging.error(f"Did not get wanted response when getting offers: {e}") + return [{}] + + def __get_offer(self, offer_id: str) -> dict: + response = self.client.get_trade_offer(offer_id, merge=True).get("response", {}) + trade_offer = response.get("offer", {}) + + if not trade_offer: + self.__log_trade( + "Did not get wanted response when getting offer", offer_id, "ERROR" + ) + return {} + + return trade_offer + + def __get_receipt(self, trade_id: str) -> list: + return self.client.get_trade_receipt(trade_id) + + def __accept(self, offer_id: str) -> None: + self.__log_trade("Trying to accept offer", offer_id) + + try: + self.client.accept_trade_offer(offer_id) + except Exception as e: + self.__log_trade(f"Error when confirming offer {e}", level="ERROR") + + def __decline(self, offer_id: str) -> None: + self.__log_trade("Trying to decline offer", offer_id) + self.client.decline_trade_offer(offer_id) + + def __process_offer(self, offer: dict) -> None: + offer_id = offer.get("tradeofferid", "") + + if not offer_id: + return + + if offer_id in self.processed: + logging.debug(f"({offer_id}) has already been processed") + return + + self.__log_trade("Processing offer", offer_id) + + # we assume we wont crash, add to processed now + # so if we return early, we wont process again + self.processed.append(offer_id) + logging.debug(f"Added offer {offer_id} to processed list") + + trade = Offer(offer) + + if not trade.is_active(): + self.__log_trade("Offer is not active") + return + + if trade.is_our_offer(): + self.__log_trade("Offer is ours, skipping") + return + + partner_steam_id = trade.get_partner() + self.__log_trade(f"Received a new offer from {partner_steam_id}") + + their_items = offer["items_to_receive"] + our_items = offer["items_to_give"] + + self.values[offer_id] = { + "their_items": their_items, + "our_items": our_items, + } + + # is owner + if partner_steam_id in self.options.owners: + self.__log_trade("Offer is from owner") + self.__accept(offer_id) + + # nothing on our side + elif trade.is_gift() and self.options.accept_donations: + self.__log_trade("User is trying to give items") + self.__accept(offer_id) + + # only items on our side + elif trade.is_scam() and self.options.decline_scam_offers: + self.__log_trade("User is trying to take items") + self.__decline(offer_id) + + # trade hold/escrow + elif trade.has_trade_hold() and self.options.decline_trade_hold: + self.__log_trade("User has trade hold") + self.__decline(offer_id) + + # two sided offer -> valuate + elif trade.is_two_sided(): + self.__log_trade("Offer is valid, calculating...") + + # all skus in our database + all_skus = self.db.get_skus() + + # get mann co key buy and sell price + key_prices = self.db.get_item("5021;6") + + # we dont care about unpriced items on their side + their_value, _ = valuate( + self.db, their_items, "buy", all_skus, key_prices, self.options + ) + our_value, has_unpriced = valuate( + self.db, our_items, "sell", all_skus, key_prices, self.options + ) + # all prices are in scrap + + item_amount = len(their_items) + len(our_items) + self.__log_trade(f"Offer contains {item_amount} items") + + difference = to_refined(their_value - our_value) + + their_value = to_refined(their_value) + our_value = to_refined(our_value) + + summary = ( + "Their value: {} ref, our value: {} ref, difference: {} ref".format( + their_value, + our_value, + refinedify(difference), + ) + ) + + self.values[offer_id]["our_value"] = our_value + self.values[offer_id]["their_value"] = their_value + + self.__log_trade(summary) + + if has_unpriced: + self.__log_trade( + "Offer contains unpriced items on our side, ignoring offer" + ) + + elif their_value >= our_value: + self.__accept(offer_id) + + # their_value < our_value + elif self.options.decline_bad_offers: + self.__decline(offer_id) + + else: + self.__log_trade("Ignoring offer as automatic decline is disabled") + + else: + self.__log_trade("Offer is invalid, ignoring...") + + def __process_offers(self) -> None: + offers = self.__get_offers() + amount = len(offers) + + if not amount: + logging.info("No new offers available") + return + + logging.info(f"Processing {amount} offers") + + for offer in offers: + self.__process_offer(offer) + + def __update_offer_states(self) -> None: + active_offers = 0 + + for offer_id in self.processed: + offer = self.__get_offer(offer_id) + trade = Offer(offer) + + if trade.is_active(): + active_offers += 1 + continue + + if not trade.is_active(): + self.__log_trade( + f"Offer state changed to {trade.get_state()}", offer_id + ) + # we have processed the offer if its not active anymore + self.processed.remove(offer_id) + + if trade.is_accepted(): + if self.options.save_trades: + logging.info("Saving offer data...") + + if offer_id in self.values: + offer["their_items"] = self.values[offer_id]["their_items"] + offer["our_items"] = self.values[offer_id]["our_items"] + offer["our_value"] = self.values[offer_id].get("our_value", 0.0) + offer["their_value"] = self.values[offer_id].get( + "their_value", 0.0 + ) + + if offer.get("tradeid") and self.options.save_receipt: + offer["receipt"] = self.__get_receipt(offer["tradeid"]) + + self.db.insert_trade(offer) + logging.info("Offer was added to the database") + + if offer_id in self.values: + self.values.pop(offer_id) + + if active_offers: + logging.info(f"{active_offers} offer(s) are still active") + + def __append_autopriced_items(self) -> None: + autopriced_items = self.db.get_autopriced() + + self.pricer.request_access_token() + + for sku in autopriced_items: + # get price + price = self.pricer.get_price(sku) + self.prices_to_check.append(price) + time.sleep(2) + + def run(self) -> None: + logging.info(f"Fetching offers every {self.options.poll_interval} seconds") + + self.__append_autopriced_items() + self.__update_prices() + + while True: + # bot is disabled + if not self.is_enabled: + time.sleep(1) + continue + + # we want to wait for TIMEOUT seconds before fetching again + if self.last_offer_fetch + self.options.poll_interval > time.time(): + # sleep 100 ms to limit cpu usage + time.sleep(0.1) + continue + + self.__update_prices() + + logging.debug(f"Currently in processed: {self.processed}") + self.__process_offers() + self.__update_offer_states() diff --git a/express/files/logs.txt b/express/files/logs.txt deleted file mode 100644 index 8b13789..0000000 --- a/express/files/logs.txt +++ /dev/null @@ -1 +0,0 @@ - diff --git a/express/items.py b/express/items.py new file mode 100644 index 0000000..7a68ce9 --- /dev/null +++ b/express/items.py @@ -0,0 +1,22 @@ +from tf2_utils import Item as UtilsItem + + +class Item(UtilsItem): + def __init__(self, item: dict) -> None: + super().__init__(item) + + def is_pure(self) -> bool: + return self.is_craftable() and ( + self.has_name("Refined Metal") + or self.has_name("Reclaimed Metal") + or self.has_name("Scrap Metal") + ) + + def get_pure(self) -> float: + if self.has_name("Refined Metal"): + return 1.00 + elif self.has_name("Reclaimed Metal"): + return 0.33 + elif self.has_name("Scrap Metal"): + return 0.11 + return 0.00 diff --git a/express/logging.py b/express/logging.py deleted file mode 100644 index 676e9b7..0000000 --- a/express/logging.py +++ /dev/null @@ -1,59 +0,0 @@ -from datetime import date, datetime - -from .config import DEBUG - -from colorama import Fore as f -from colorama import init - -init() - -logs = [] - - -def write_to_txt_file(strings: list): - to_write = "" - - for string in strings: - to_write += "\n" + string - - with open("express/files/logs.txt", "a") as f: - f.write(to_write) - - -def log(color: int, sort: str, bot: str, text: str, offer_id: str = ""): - name = f" {f.WHITE}[{f.GREEN}{bot}{f.WHITE}]" if bot else "" - text = f"({f.YELLOW}{offer_id}{f.WHITE}) {text}" if offer_id else text - time = datetime.now().time().strftime("%H:%M:%S") - string = "{}tf2-express | {}" + time + " - {}" + sort + "{}{}: " + text + "{}" - - if DEBUG: - logs.append( - f"{date.today().strftime('%d/%m/%Y')} @ {time} | {sort} {bot}: {text}" - ) - - if len(logs) >= 10: - write_to_txt_file(logs) - logs.clear() - - print(string.format(f.GREEN, f.WHITE, color, name, f.WHITE, f.WHITE)) - - -class Log: - def __init__(self, bot: str = "", offer_id: str = ""): - self.bot = bot - self.offer_id = offer_id - - def info(self, text: str): - log(f.GREEN, "info", self.bot, text) - - def error(self, text: str): - log(f.RED, "error", self.bot, text) - - def trade(self, text: str, offer_id: str = ""): - log(f.MAGENTA, "trade", self.bot, text, self.offer_id or offer_id) - - def debug(self, text: str, offer_id: str = ""): - log(f.CYAN, "debug", self.bot, text, self.offer_id or offer_id) - - def close(self): - write_to_txt_file(logs) diff --git a/express/methods.py b/express/methods.py deleted file mode 100644 index 50607a9..0000000 --- a/express/methods.py +++ /dev/null @@ -1,15 +0,0 @@ -from json import loads - -import requests - - -FAILURE = {"success": False} - - -def request(url: str, payload: dict = {}, headers: dict = {}) -> dict: - r = requests.get(url, params=payload, headers=headers) - - try: - return loads(r.text) - except ValueError: - return {**FAILURE, "status_code": r.status_code, "text": r.text} diff --git a/express/offer.py b/express/offer.py index 6629d4f..b4d9bc4 100644 --- a/express/offer.py +++ b/express/offer.py @@ -1,98 +1,69 @@ -from .settings import decline_trade_hold -from .prices import get_price, get_key_price -from .config import OWNERS -from .utils import Item, to_scrap +from .database import Database +from .options import Options +from .items import Item -from steampy.models import TradeOfferState -from steampy.utils import account_id_to_steam_id +import logging +from tf2_utils import get_sku, to_scrap -def valuate(items: dict, intent: str, item_list: list) -> int: - total = 0 - high = float(10 ** 10) - - for i in items: - item = Item(items[i]) - name = item.name - value = 0.00 - - if item.is_tf2(): - - if item.is_pure(): - value = item.get_pure() - - elif item.is_key(): - value = get_key_price() - - elif item.is_craftable(): - - if name in item_list: - value = get_price(name, intent) - - elif item.is_craft_hat(): - value = get_price("Random Craft Hat", intent) - - elif not item.is_craftable(): - name = "Non-Craftable " + name - - if name in item_list: - value = get_price(name, intent) - - if not value: - value = high if intent == "sell" else 0.00 - - total += to_scrap(value) - - return total +def valuate( + db: Database, + items: dict, + intent: str, + all_skus: list, + key_prices: dict | None, + options: Options, +) -> tuple[int, bool]: + has_unpriced = False + total = 0 -class Offer: - def __init__(self, offer: dict): - self.offer = offer - self.state = offer["trade_offer_state"] + key_price = 0.0 - def get_state(self) -> str: - return TradeOfferState(self.state).name + if key_prices: + key_price = key_prices[intent]["metal"] + else: + logging.warning("No key price found, will valuate keys at 0 ref") - def has_state(self, state: int) -> bool: - return self.state == state + for i in items: + # valute one item at a time + item = Item(items[i]) + sku = get_sku(item) + keys = 0 + metal = 0.00 - def is_active(self) -> bool: - return self.has_state(2) + # TODO: what if intent is sell and the item is "fake"? + if not item.is_tf2() and intent == "buy": + # we dont add any price for that item -> skip + continue - def is_accepted(self) -> bool: - return self.has_state(3) + elif item.is_key(): + keys = 1 - def is_declined(self) -> bool: - return self.has_state(7) + elif item.is_pure(): # should be metal / add keys to pure + metal = item.get_pure() - def has_escrow(self) -> bool: - return self.offer["escrow_end_date"] == 0 + # has a specifc price + elif sku in all_skus: + keys, metal = db.get_price(sku, intent) - def is_our_offer(self) -> bool: - return self.offer["is_our_offer"] + # TODO: AND ALLOW CRAFT WEPS + # elif item.is_craft_weapon(): + # keys, metal = db.get_price("-50;6", intent) - def is_gift(self) -> bool: - return self.offer.get("items_to_receive") and not self.offer.get( - "items_to_give" - ) + elif item.is_craft_hat() and options.allow_craft_hats: + keys, metal = db.get_price("-100;6", intent) - def is_scam(self) -> bool: - return self.offer.get("items_to_give") and not self.offer.get( - "items_to_receive" - ) + value = keys * to_scrap(key_price) + to_scrap(metal) - def is_valid(self) -> bool: - return ( - True - if self.offer.get("items_to_receive") - and self.offer.get("items_to_give") - and (self.has_escrow() or self.has_escrow() == decline_trade_hold) - else False - ) + if not value and intent == "sell": + # dont need to process rest of offer since we cant know the total price + has_unpriced = True + break - def get_partner(self) -> str: - return account_id_to_steam_id(self.offer["accountid_other"]) + # handle craft weapons + # total += value if value >= 1 else 0.50 + total += value - def is_from_owner(self) -> bool: - return self.get_partner() in OWNERS + # total scrap + return total, has_unpriced diff --git a/express/options.py b/express/options.py new file mode 100644 index 0000000..896ea39 --- /dev/null +++ b/express/options.py @@ -0,0 +1,22 @@ +from dataclasses import dataclass, field + + +@dataclass +class Options: + accept_donations: bool = False + decline_bad_offers: bool = False + decline_trade_hold: bool = False + decline_scam_offers: bool = False + allow_craft_hats: bool = False + save_trades: bool = True + save_receipt: bool = False + poll_interval: int = 30 + database: str = "express" + owners: list[str] = field(default_factory=list) + + +@dataclass +class GlobalOptions: + bots: list[dict] + name: str = "express user" # nickname + check_versions_on_startup: bool = True diff --git a/express/prices.py b/express/prices.py deleted file mode 100644 index 0870047..0000000 --- a/express/prices.py +++ /dev/null @@ -1,52 +0,0 @@ -from .database import update_price, get_item -from .database import _get_price -from .methods import request -from .logging import Log -from .utils import to_scrap, to_refined - - -log = Log() - - -def get_key_price() -> float: - return _get_price("Mann Co. Supply Crate Key")["buy"]["metal"] - - -def get_pricelist() -> dict: - log.info("Trying to get prices...") - return request("https://api.prices.tf/items", {"src": "bptf"}) - - -def get_price(name: str, intent: str) -> float: - price = _get_price(name)[intent] - metal = to_scrap(price["metal"]) - keys = price.get("keys") - - if keys: - metal += to_scrap(keys * get_key_price()) - - return to_refined(metal) - - -def update_pricelist(items: list) -> None: - pricelist = get_pricelist() - - if not pricelist.get("items"): - log.error("Could not get pricelist") - return - - for i in pricelist["items"]: - name = i["name"] - - if name in items: - item = get_item(name) - - if item.get("autoprice"): - if not (item["buy"] or item["sell"]): - update_price(name, True, i["buy"], i["sell"]) - - elif not (i["buy"] == item["buy"]) or not (i["sell"] == item["sell"]): - update_price(name, True, i["buy"], i["sell"]) - - # Does not warn the user if an item in the database - # can't be found in Prices.TF's pricelist diff --git a/express/settings.py b/express/settings.py deleted file mode 100644 index ccb5328..0000000 --- a/express/settings.py +++ /dev/null @@ -1,6 +0,0 @@ -accept_donations = True -decline_bad_trade = False -decline_trade_hold = True -decline_scam_offers = True -allow_craft_hats = True -save_trades = True diff --git a/express/ui/panel.py b/express/ui/panel.py deleted file mode 100644 index be8bf16..0000000 --- a/express/ui/panel.py +++ /dev/null @@ -1,92 +0,0 @@ -# to run: python -m express.ui.panel - -from flask import Flask, render_template, request, redirect, abort - - -from ..database import * -from ..prices import get_pricelist - - -import json - - -app = Flask(__name__) - - -@app.route("/") -def overview(): - return redirect("/prices") - - -@app.route("/trades") -def trades(): - return render_template("trades.html", trades=get_trades()) - - -@app.route("/prices") -def prices(): - return render_template("prices.html", items=get_database_pricelist()) - - -@app.route("/pricelist") -def pricelist(): - with open("./express/ui/pricelist.json", "w") as f: - json.dump(get_pricelist(), f) - return redirect("/prices") - - -@app.route("/price/") -def price(name): - try: - pricelist = json.loads(open("./express/ui/pricelist.json", "r").read()) - item = {} - - for i in pricelist["items"]: - if name == i["name"]: - item = i - - if not (item.get("buy") and item["sell"]): - return redirect("/prices") - - update_price(name, True, item["buy"], item["sell"]) - return redirect("/prices") - - except FileNotFoundError: - return redirect("/pricelist") - - -@app.route("/delete/") -def delete(name): - remove_price(name) - return redirect("/prices") - - -@app.route("/edit", methods=["POST"]) -def edit(): - data = dict(request.form.items()) - - print(data) - - name, buy, sell = ( - data["name"], - {"keys": int(data["buy_keys"]), "metal": float(data["buy_metal"])}, - {"keys": int(data["sell_keys"]), "metal": float(data["sell_metal"])}, - ) - - update_price(name, False, buy, sell) - - return redirect("/prices") - - -@app.route("/add", methods=["POST"]) -def add(): - data = dict(request.form.items()) - names = data["names"].split(", ") - for name in names: - if not name in get_items(): - add_price(name) - return redirect("/prices") - - -if __name__ == "__main__": - app.run(debug=True) diff --git a/express/ui/templates/index.html b/express/ui/templates/index.html deleted file mode 100644 index dc797d8..0000000 --- a/express/ui/templates/index.html +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - tf2-express - - - -
-

TF2 EXPRESS

- -

Currently running: {{ data['running'] }}

- -

Last time since price update: {{ data['last_price_update'] }}

-

Items in pricelist: {{ data['items'] }}

-
- - diff --git a/express/ui/templates/prices.html b/express/ui/templates/prices.html deleted file mode 100644 index a233c87..0000000 --- a/express/ui/templates/prices.html +++ /dev/null @@ -1,77 +0,0 @@ - - - - - - - - - prices - tf2-express - - - -
-

PRICES

- -

Add multiple items by using commas

-
- - - -
-

Item prices will be added and updated when running main.py.

-

If the bot does not add its prices on startup you might have written in the wrong - item name. E.g. "Team Captain" instead of "The Team Captain".

-

Update pricelist to get all items and newer prices.

- - - - - - - - - - - - - - - {% for item in items %} - - - - - - - - - - - - - {% endfor %} - -
Item nameAutopriceBuy keysBuy refinedSell keysSell refinedAction
{{ item['name'] }} - {{ item["autoprice"] }} - - - - - - - -
-
- - - diff --git a/express/utils.py b/express/utils.py index b547722..db8653e 100644 --- a/express/utils.py +++ b/express/utils.py @@ -1,79 +1,93 @@ -from math import floor, ceil +import logging +import json -from .settings import allow_craft_hats +import requests -def to_refined(scrap: int) -> float: - return floor(scrap / 9 * 100) / 100 +def get_config() -> dict: + config = {} + with open("./express/config.json", "r") as f: + config = json.loads(f.read()) -def to_scrap(refined: float) -> int: - return ceil(refined * 9) + return config -def refinedify(value: float) -> float: - return ( - floor((round(value * 9, 0) * 100) / 9) / 100 - if value > 0 - else ceil((round(value * 9, 0) * 100) / 9) / 100 - ) +def summarize_items(items: list[dict]) -> dict: + summary = {} + for item in items: + item_name = items[item]["market_hash_name"] -class Item: - def __init__(self, item: dict): - self.item = item - self.name = item["market_hash_name"] + if item_name not in summary: + summary[item_name] = {"count": 1, "image": items[item]["icon_url"]} + else: + summary[item_name]["count"] += 1 - def is_tf2(self) -> bool: - return self.item["appid"] == 440 + return summary - def has_tag(self, tag: str) -> bool: - for i in self.item["tags"]: - if i["localized_tag_name"] == tag: - return True - return False - def has_name(self, name: str) -> bool: - return self.name == name +def summarize_trades(trades: list[dict]) -> list[dict]: + summary = [] - def has_description(self, description: str) -> bool: - if "descriptions" not in self.item: - return False + for trade in trades: + their_items_summary = summarize_items(trade.get("their_items", [])) + our_items_summary = summarize_items(trade.get("our_items", [])) - for i in self.item["descriptions"]: - if i["value"] == description: - return True - return False + summary.append( + { + **trade, + "our_summary": our_items_summary, + "their_summary": their_items_summary, + } + ) - def is_craftable(self) -> bool: - return not self.has_description("( Not Usable in Crafting )") + return summary - def is_halloween(self) -> bool: - return self.has_description("Holiday Restriction: Halloween / Full Moon") - def is_craft_hat(self) -> bool: - return ( - self.is_craftable() - and not self.is_halloween() - and self.has_tag("Cosmetic") - and allow_craft_hats - ) +def get_version(repository: str, folder: str) -> str: + url = f"https://raw.githubusercontent.com/offish/{repository}/master/{folder}/__init__.py" - def is_key(self) -> bool: - return self.is_craftable() and self.has_name("Mann Co. Supply Crate Key") + r = requests.get(url) + data = r.text - def is_pure(self) -> bool: - return self.is_craftable() and ( - self.has_name("Refined Metal") - or self.has_name("Reclaimed Metal") - or self.has_name("Scrap Metal") - ) + version_index = data.index("__version__") + start_quotation_mark = data.index('"', version_index) + end_quotation_mark = data.index('"', start_quotation_mark + 1) + + # get rid of first " + return data[start_quotation_mark + 1 : end_quotation_mark] + + +class ExpressFormatter(logging.Formatter): + _format = "tf2-express | %(asctime)s - [%(levelname)s]: %(message)s" + + FORMATS = { + logging.DEBUG: _format, + logging.INFO: _format, + logging.WARNING: _format, + logging.ERROR: _format + "(%(filename)s:%(lineno)d)", + logging.CRITICAL: _format + "(%(filename)s:%(lineno)d)", + } + + def format(self, record): + log_fmt = self.FORMATS.get(record.levelno) + formatter = logging.Formatter(log_fmt, datefmt="%H:%M:%S") + return formatter.format(record) + + +class ExpressFileFormatter(logging.Formatter): + _format = "%(filename)s %(asctime)s - [%(levelname)s]: %(message)s" + + FORMATS = { + logging.DEBUG: _format, + logging.INFO: _format, + logging.WARNING: _format, + logging.ERROR: _format + "(%(filename)s:%(lineno)d)", + logging.CRITICAL: _format + "(%(filename)s:%(lineno)d)", + } - def get_pure(self) -> float: - if self.has_name("Refined Metal"): - return 1.00 - elif self.has_name("Reclaimed Metal"): - return 0.33 - elif self.has_name("Scrap Metal"): - return 0.11 - return 0.00 + def format(self, record): + log_fmt = self.FORMATS.get(record.levelno) + formatter = logging.Formatter(log_fmt, datefmt="%d/%m/%Y %H:%M:%S") + return formatter.format(record) diff --git a/gui.bat b/gui.bat deleted file mode 100644 index 8350c4b..0000000 --- a/gui.bat +++ /dev/null @@ -1,4 +0,0 @@ -@echo off -title tf2-express-gui -python -m express.ui.panel -pause diff --git a/main.py b/main.py index 5914c89..f3bac81 100644 --- a/main.py +++ b/main.py @@ -1,213 +1,123 @@ -from multiprocessing import Process, Pool -from time import sleep, time - -from express.database import * -from express.settings import * -from express.logging import Log, f -from express.prices import update_pricelist -from express.config import * -from express.client import Client -from express.offer import Offer, valuate -from express.utils import to_refined, refinedify - -import socketio - - -def run(bot: dict) -> None: - try: - client = Client(bot) - client.login() - - log = Log(bot["name"]) - - processed = [] - values = {} - - log.info(f"Polling offers every {TIMEOUT} seconds") - - while True: - log = Log(bot["name"]) - log.debug("Polling offers...") - offers = client.get_offers() - - for offer in offers: - offer_id = offer["tradeofferid"] - - if offer_id not in processed: - log = Log(bot["name"], offer_id) - - trade = Offer(offer) - steam_id = trade.get_partner() - - if trade.is_active() and not trade.is_our_offer(): - log.trade(f"Received a new offer from {f.YELLOW + steam_id}") - - if trade.is_from_owner(): - log.trade("Offer is from owner") - client.accept(offer_id) - - elif trade.is_gift() and accept_donations: - log.trade("User is trying to give items") - client.accept(offer_id) - - elif trade.is_scam() and decline_scam_offers: - log.trade("User is trying to take items") - client.decline(offer_id) - - elif trade.is_valid(): - log.trade("Processing offer...") - - their_items = offer["items_to_receive"] - our_items = offer["items_to_give"] - - items = get_items() - - their_value = valuate(their_items, "buy", items) - our_value = valuate(our_items, "sell", items) - - item_amount = len(their_items) + len(our_items) - log.trade(f"Offer contains {item_amount} items") - - difference = to_refined(their_value - our_value) - summary = "User value: {} ref, our value: {} ref, difference: {} ref" - - log.trade( - summary.format( - to_refined(their_value), - to_refined(our_value), - refinedify(difference), - ) - ) - - if their_value >= our_value: - values[offer_id] = { - "our_value": to_refined(our_value), - "their_value": to_refined(their_value), - } - client.accept(offer_id) - - else: - if decline_bad_trade: - client.decline(offer_id) - else: - log.trade( - "Ignoring offer as automatic decline is disabled" - ) - - else: - log.trade("Offer is invalid") - - else: - log.trade("Offer is not active") - - processed.append(offer_id) - - del offers - - for offer_id in processed: - offer = client.get_offer(offer_id) - trade = Offer(offer) - - log = Log(bot["name"], offer_id) - - if not trade.is_active(): - state = trade.get_state() - log.trade(f"Offer state changed to {f.YELLOW + state}") - - if trade.is_accepted() and "tradeid" in offer: - if save_trades: - Log().info("Saving offer data...") - - if offer_id in values: - offer["our_value"] = values[offer_id]["our_value"] - offer["their_value"] = values[offer_id]["their_value"] - - offer["receipt"] = client.get_receipt(offer["tradeid"]) - add_trade(offer) - - if offer_id in values: - values.pop(offer_id) - - processed.remove(offer_id) - - sleep(TIMEOUT) - - except BaseException as e: - log.info(f"Caught {type(e).__name__}") - - try: - client.logout() - except: - pass - - log.info(f"Stopped") - - -def database() -> None: - try: - items_in_database = get_items() - log = Log() - - while True: - if not items_in_database == get_items(): - log.info("Item(s) were added or removed from the database") - items_in_database = get_items() - update_pricelist(items_in_database) - log.info("Successfully updated all prices") - sleep(10) - - except BaseException: - pass - - -if __name__ == "__main__": - t1 = time() - log = Log() - - try: - socket = socketio.Client() - - items = get_items() - update_pricelist(items) - log.info("Successfully updated all prices") - - del items - - @socket.event - def connect(): - socket.emit("authentication") - log.info("Successfully connected to Prices.TF socket server") - - @socket.event - def authenticated(data): - pass - - @socket.event - def price(data): - if data["name"] in get_items(): - update_price(data["name"], True, data["buy"], data["sell"]) - - @socket.event - def unauthorized(sid): - pass - - socket.connect("https://api.prices.tf") - log.info("Listening to Prices.TF for price updates") - - process = Process(target=database) - process.start() - - with Pool(len(BOTS)) as p: - p.map(run, BOTS) - - except BaseException as e: - if e: - log.error(e) - - finally: - socket.disconnect() - process.terminate() - t2 = time() - log.info(f"Done. Bot ran for {round(t2-t1, 1)} seconds") - log.close() - quit() +from express.express import Express +from express.options import Options, GlobalOptions +from express.utils import ( + ExpressFormatter, + ExpressFileFormatter, + get_version, + get_config, +) + +from threading import Thread +import logging +import sys +import os + +from tf2_utils import PricesTFSocket +from tf2_utils import __version__ as tf2_utils_version +from express import __version__ as tf2_express_version +from tf2_sku import __version__ as tf2_sku_version + + +bots: list[Express] = [] + +packages = [ + (tf2_express_version, "tf2-express", "express"), + (tf2_utils_version, "tf2-utils", "src/tf2_utils"), + (tf2_sku_version, "tf2-sku", "src/tf2_sku"), +] + +formatter = ExpressFormatter() +stream_handler = logging.StreamHandler(sys.stdout) + +LOG_PATH = os.getcwd() + "/logs/" +LOG_FILE = LOG_PATH + "express.log" + +# create folder and empty log file +if not os.path.isfile(LOG_FILE): + os.makedirs(LOG_PATH) + open(LOG_FILE, "x") + +file_handler = logging.FileHandler(LOG_FILE, encoding="utf-8") + +logging.basicConfig( + level=logging.DEBUG, + handlers=[ + stream_handler, + file_handler, + ], +) + +# only want to see info and above in console +stream_handler.setLevel(logging.INFO) +# stream_handler.setLevel(logging.DEBUG) +stream_handler.setFormatter(ExpressFormatter()) + +# want to have everything in the log file +file_handler.setLevel(logging.DEBUG) +file_handler.setFormatter(ExpressFileFormatter()) + + +def create_bot_instance(bot: dict) -> Express: + options = Options(**bot["options"]) + logging.debug(f"using options {options=}") + client = Express(bot, options) + + logging.info(f"Created instance for {bot['username']}") + return client + + +# callback to tf2-utils' pricestf +def on_price_change(data: dict) -> None: + if data.get("type") != "PRICE_CHANGED": + return + + if not data.get("data", {}).get("sku"): + return + + logging.debug(f"Got new price change from Prices.tf {data=}") + + for bot in bots: + bot.append_new_price(data.get("data", {})) + + +if __name__ == "__main__": + logging.info("Started tf2-express") + + config = get_config() + options = GlobalOptions(**config) + + prices_tf_socket = PricesTFSocket(on_price_change) + prices_thread = Thread(target=prices_tf_socket.listen, daemon=True) + + logging.info("Listening to Prices.tf for price updates") + + if options.check_versions_on_startup: + for i in packages: + current_version, repo, folder = i + latest_version = get_version(repo, folder) + + if current_version == latest_version: + continue + + logging.warning( + "{} is at version {} while {} is available".format( + repo, current_version, latest_version + ) + ) + + prices_thread.start() + bot_threads: list[Thread] = [] + + for bot in options.bots: + bot_instance = create_bot_instance(bot) + bot_instance.login() + bot_thread = Thread(target=bot_instance.run, daemon=True) + bot_thread.start() + + bots.append(bot_instance) + bot_threads.append(bot_thread) + + for thread in bot_threads: + thread.join() + + prices_thread.join() diff --git a/panel.py b/panel.py new file mode 100644 index 0000000..ca673ec --- /dev/null +++ b/panel.py @@ -0,0 +1,237 @@ +from express.database import Database +from express.utils import summarize_trades + +import json + +from flask import Flask, render_template, request, redirect +from tf2_utils import SchemaItemsUtils, refinedify +from tf2_data import COLORS +from tf2_utils import __version__ as tf2_utils_version +from tf2_data import __version__ as tf2_data_version +from tf2_sku import __version__ as tf2_sku_version +from express import __version__ as tf2_express_version + + +app = Flask(__name__) +name = "" + +databases: dict = {} +schema_items_utils = SchemaItemsUtils() +first_database = "" + + +def is_sku(item: str) -> bool: + return item.find(";") != -1 + + +def sku_to_color(sku: str) -> str: + quality = sku.split(";")[1:][0] + return COLORS[quality] + + +def items_to_data(items: list, skus: list) -> list: + data = [] + + for item in items: + # check if first char is whitespace + if item.find(" ") == 0: + item = item[1:] + + sku = item + + if not is_sku(sku): + sku = schema_items_utils.name_to_sku(item) + + name = schema_items_utils.sku_to_name(sku) + + if sku in skus: + print(f"{sku=} already exists in the database") + continue + + if not name: + print(f"could not get name for {sku=}, ignoring...") + continue + + color = sku_to_color(sku) + image = schema_items_utils.sku_to_image_url(sku) + + data.append({"sku": sku, "name": name, "image": image, "color": color}) + + return data + + +def add_items_to_database(db, data: list) -> None: + for item in data: + db.add_price(**item) + + return + + +def get_config() -> dict: + config = {} + + with open("./express/config.json", "r") as f: + config = json.loads(f.read()) + + return config + + +def get_database(request) -> tuple[Database, str]: + default = first_database + db_name = request.args.get("db", default) + + if not db_name: + db_name = default + + return databases[db_name], db_name + + +def render(page: str, db_name: str, **kwargs) -> str: + return render_template( + f"{page}.html", db_name=db_name, database_names=list(databases.keys()), **kwargs + ) + + +@app.route("/") +def overview(): + _, db_name = get_database(request) + + return render( + "home", + db_name, + name=name, + tf2_express_version=tf2_express_version, + tf2_utils_version=tf2_utils_version, + tf2_data_version=tf2_data_version, + tf2_sku_version=tf2_sku_version, + ) + + +@app.route("/trades") +def trades(): + db, db_name = get_database(request) + + start = request.args.get("start", 0) + amount = request.args.get("amount", 25) + + if not isinstance(start, int): + start = int(start) + + if not isinstance(amount, int): + amount = int(amount) + + selected_trades, total_trades, start_index, end_index = db.get_trades(start, amount) + + summarized_trades = summarize_trades(selected_trades) + + return render( + "trades", + db_name, + trades=summarized_trades, + total_trades=total_trades, + start=start, + amount=amount, + start_index=start_index, + end_index=end_index, + ) + + +@app.route("/item/") +def item_info(sku): + db, db_name = get_database(request) + item = db.get_item(sku) + + return render("item", db_name, item=item) + + +@app.route("/items") +def items(): + db, db_name = get_database(request) + + return render("items", db_name, items=db.get_pricelist()) + + +@app.route("/autoprice/") +def autoprice(sku): + db, db_name = get_database(request) + db.update_price(sku, {}, {}, True) + + return redirect(f"/items?db={db_name}") + + +@app.route("/delete/") +def delete(sku): + db, db_name = get_database(request) + db.delete_price(sku) + + return redirect(f"/items?db={db_name}") + + +@app.route("/edit", methods=["POST"]) +def edit(): + db, db_name = get_database(request) + data = dict(request.form.items()) + + buy_keys = data["buy_keys"] + buy_metal = data["buy_metal"] + + sell_keys = data["sell_keys"] + sell_metal = data["sell_metal"] + + if not buy_keys: + buy_keys = 0 + + if not sell_keys: + sell_keys = 0 + + if not buy_metal: + buy_metal = 0.0 + + if not sell_metal: + sell_metal = 0.0 + + db.update_price( + sku=data["sku"], + buy={ + "keys": int(buy_keys), + "metal": refinedify(float(buy_metal)), + }, + sell={ + "keys": int(sell_keys), + "metal": refinedify(float(sell_metal)), + }, + autoprice=False, + ) + + return redirect(f"/items?db={db_name}") + + +@app.route("/add", methods=["POST"]) +def add(): + db, db_name = get_database(request) + data = dict(request.form.items()) + items = data["items"].split(",") + skus = db.get_skus() + data = items_to_data(items, skus) + add_items_to_database(db, data) + + return redirect(f"/items?db={db_name}") + + +if __name__ == "__main__": + config = get_config() + + for bot in config["bots"]: + options = bot.get("options", {}) + db = options.get("database", "express") + host = options.get("host", "localhost") + port = options.get("port", 27017) + + if not first_database: + first_database = db + + databases[db] = Database(db, host, port) + + name = config.get("name", "express user") + + app.run(debug=True) diff --git a/requirements.txt b/requirements.txt index 2e0f08f..4ff8aad 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,8 @@ -steampy -colorama +steampy>=1.1.0 +tf2-sku>=2.0.1 +tf2-data>=0.0.4 +tf2-utils>=2.0.6 requests -python-socketio pymongo flask -python-engineio==3.14.2 +black diff --git a/start.bat b/start.bat deleted file mode 100644 index dcc4dfe..0000000 --- a/start.bat +++ /dev/null @@ -1,4 +0,0 @@ -@echo off -title tf2-express -python main.py -pause diff --git a/static/favicon.ico b/static/favicon.ico new file mode 100644 index 0000000..1975612 Binary files /dev/null and b/static/favicon.ico differ diff --git a/static/main.css b/static/main.css new file mode 100644 index 0000000..a570097 --- /dev/null +++ b/static/main.css @@ -0,0 +1,28 @@ +:root { + --green: #4CFF00; + --red: #BE1A1A; + --black: #1D1D1D; + --link-green: #72D587; +} + +.table-dark { + --bs-table-bg: var(--black); +} + +body { + background-color: var(--black); + color: white; +} + +h1 { + color: var(--green); + margin-top: 5%; +} + +h2 { + margin-top: 2.5%; +} + +a { + color: var(--link-green); +} \ No newline at end of file diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..c777ddc --- /dev/null +++ b/templates/base.html @@ -0,0 +1,51 @@ + + + + + + + + + + + {% block title %}{% endblock %} | tf2-express + + +
+ +
+ + +
+ {% block contents %} + {% endblock %} +
+ + + \ No newline at end of file diff --git a/templates/home.html b/templates/home.html new file mode 100644 index 0000000..3d2e7b5 --- /dev/null +++ b/templates/home.html @@ -0,0 +1,36 @@ +{% extends "base.html" %} + +{% block title %} +Home +{% endblock %} + +{% block contents %} +

Welcome back, {{ name }}

+ +

Useful information

+

You are running

+
    +
  • tf2-express at v{{ tf2_express_version }} repository
  • +
  • tf2-utils at v{{ tf2_utils_version }} repository
  • +
  • tf2-data at v{{ tf2_data_version }} repository
  • +
  • tf2-sku at v{{ tf2_sku_version }} repository
  • + +
+ +

Currently viewing data from "{{ db_name }}" database. If you are running multiple bots, you might want to change the + database you are viewing.

+

Other links

+ + +

Support me?

+

These sort of things takes time to make. Donations are not required, but greatly appericated!

+ + + +{% endblock %} \ No newline at end of file diff --git a/templates/item.html b/templates/item.html new file mode 100644 index 0000000..e47273e --- /dev/null +++ b/templates/item.html @@ -0,0 +1,42 @@ +{% extends "base.html" %} + +{% block title %} +{{ item.name }} +{% endblock %} + +{% block contents %} +

{{ item.sku }}

+ +

{{ item.name }}

+{% if item.autoprice %} +

Autopriced

+{% else %} +

Not autopriced

+{% endif %} +

Last updated: {{ item.updated }}

+ + + +

Buy

+

Keys: {{ item["buy"]["keys"] }}

+

Metal: {{ item["buy"]["metal"] }}

+ +

Sell

+

Keys: {{ item["sell"]["keys"] }}

+

Metal: {{ item["sell"]["metal"] }}

+ +

Stock

+

In stock: TODO

+

Max stock: TODO

+ +{% endblock %} \ No newline at end of file diff --git a/templates/items.html b/templates/items.html new file mode 100644 index 0000000..643ee87 --- /dev/null +++ b/templates/items.html @@ -0,0 +1,81 @@ +{% extends "base.html" %} + +{% block title %} +Items +{% endblock %} + +{% block contents %} +

Items

+ + +
+ + + + +
+ +

Items which are autopriced will get/update their price when starting the bot.

+ + + + + + + + + + + + + + + + {% for item in items %} + + + + + + + + + + + + + + + + + + {% endfor %} + +
IconSKUAutopricedBuy keysBuy refinedSell keysSell refinedAction
+ {{ item.sku }} ({{ item.name }}){{ item.autoprice }}
+ + + + + + + + + + + +
+{% endblock %} \ No newline at end of file diff --git a/templates/trades.html b/templates/trades.html new file mode 100644 index 0000000..56a9dda --- /dev/null +++ b/templates/trades.html @@ -0,0 +1,55 @@ +{% extends "base.html" %} + +{% block title %} +Trades +{% endblock %} + +{% block contents %} +

Trades

+ + + + +Showing {{ start_index }}-{{ end_index }} ({{ end_index - start_index }}) of {{ total_trades }} total trades.

+ + + +{% for trade in trades %} +
+

+

Trade #{{ trade.tradeofferid }}

+ {% if trade.message %} +

Message: {{ trade.message }}

+ {% endif %} +

Created: {{ trade.time_created }}

+

Accepted: {{ trade.time_updated }}

+ + {% if trade.our_items %} +

Our items ({{ trade.our_items|length }}x)

+

Our value: {{ trade.our_value }} refined

+ {% for item in trade.our_summary %} +
+ + {{ trade.our_summary[item].count }}x {{ item }} +
+ {% endfor %} + {% endif %} + + {% if trade.their_items %} +

Their items ({{ trade.their_items|length }}x)

+

Their value: {{ trade.their_value }} refined

+ {% for item in trade.their_summary %} +
+ + {{ trade.their_summary[item].count }}x {{ item }} +
+ {% endfor %} + {% endif %} +
+{% endfor %} +{% endblock %} \ No newline at end of file