diff --git a/.flake8 b/.flake8 new file mode 100644 index 00000000..5feaaac1 --- /dev/null +++ b/.flake8 @@ -0,0 +1,3 @@ +[flake8] +ignore = E501, W605, B902, N999, W503, SIM102 +inline-quotes = " \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..1040025d --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,13 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: "pip" + directory: "/" # Location of package manifests + schedule: + interval: "weekly" + labels: + - "dependencies" diff --git a/.github/workflows/formatting.yaml b/.github/workflows/formatting.yaml new file mode 100644 index 00000000..a118565f --- /dev/null +++ b/.github/workflows/formatting.yaml @@ -0,0 +1,19 @@ +name: Contributions formatting checker +on: [pull_request] +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v1 + - name: Set up Python 3.8 + uses: actions/setup-python@v1 + with: + python-version: 3.8 + - name: Install Black + run: pip install black + - name: Run black --check . + run: black --check . + - name: Install isort + run: pip install isort + - name: Run isort --check . + run: isort . --check --settings-path ./.isort.cfg \ No newline at end of file diff --git a/.gitignore b/.gitignore index 4f595c24..f7445e67 100644 --- a/.gitignore +++ b/.gitignore @@ -1,139 +1,142 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -pip-wheel-metadata/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -.python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# Custom -data/ -config/ -binance_trade_bot/ -user.cfg -user.cfg.backup -supported_coin_list -supported_coin_list.backup +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# PyCharm +.idea/ + +# Custom +data/ +config/ +binance_trade_bot/ +user.cfg +user.cfg.backup +supported_coin_list +supported_coin_list.backup logs/ \ No newline at end of file diff --git a/.isort.cfg b/.isort.cfg new file mode 100644 index 00000000..69d6c5c7 --- /dev/null +++ b/.isort.cfg @@ -0,0 +1,2 @@ +[settings] +profile = black \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..74c9da2f --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,10 @@ +repos: + - repo: https://github.com/psf/black + rev: 20.8b1 + hooks: + - id: black + language_version: python3 + - repo: https://github.com/pycqa/isort + rev: 5.5.2 + hooks: + - id: isort diff --git a/BTBManagerTelegram.py b/BTBManagerTelegram.py index 0480ef9c..3326fcac 100644 --- a/BTBManagerTelegram.py +++ b/BTBManagerTelegram.py @@ -1,347 +1,5 @@ -# -*- coding: utf-8 -*- -import logging -import yaml -import psutil -import subprocess -import os -from shutil import copyfile -from telegram import ReplyKeyboardMarkup, ReplyKeyboardRemove, Update -from telegram.ext import ( - Updater, - CommandHandler, - MessageHandler, - Filters, - ConversationHandler, - CallbackContext -) - -MENU, EDIT_COIN_LIST, EDIT_USER_CONFIG = range(3) - - -class BTBManagerTelegram: - def __init__(self, root_path='./', from_yaml=True, token=None, user_id=None): - self.root_path = root_path - logging.basicConfig( - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', level=logging.INFO - ) - self.logger = logging.getLogger(__name__) - - if from_yaml: - token, user_id = self.__get_token_from_yaml() - - - updater = Updater(token) - dispatcher = updater.dispatcher - - conv_handler = ConversationHandler( - entry_points=[CommandHandler('start', self.__start, Filters.user(user_id=eval(user_id)))], - states={ - MENU: [MessageHandler(Filters.regex('^(Begin|⚠ Check bot status|👛 Edit coin list|▶ Start trade bot|⏹ Stop trade bot|❌ Delete database|⚙ Edit user.cfg|📜 Read last log lines|Go back)$'), self.__menu)], - EDIT_COIN_LIST: [MessageHandler(Filters.regex('(.*?)'), self.__edit_coin)], - EDIT_USER_CONFIG: [MessageHandler(Filters.regex('(.*?)'), self.__edit_user_config)] - }, - fallbacks=[CommandHandler('cancel', self.__cancel)], - per_user=True - ) - - dispatcher.add_handler(conv_handler) - updater.start_polling() - updater.idle() - - def __get_token_from_yaml(self): - telegram_url = None - yaml_file_path = f'{self.root_path}config/apprise.yml' - with open(yaml_file_path) as f: - parsed_urls = yaml.load(f, Loader=yaml.FullLoader)['urls'] - for url in parsed_urls: - if url.startswith('tgram'): - telegram_url = url.split('//')[1] - if not telegram_url: - print('ERROR: No telegram configuration was found in your yaml file.\nAborting.') - exit(-1) - try: - tok = telegram_url.split('/')[0] - uid = telegram_url.split('/')[1] - except: - print('ERROR: No user_id has been set in the yaml configuration, anyone would be able to control your bot.\nAborting.') - exit(-1) - return tok, uid - - - def __start(self, update: Update, _: CallbackContext) -> int: - self.logger.info('Started conversation.') - - keyboard = [['Begin']] - message = f'Hi *{update.message.from_user.first_name}*\!\nWelcome to _Binace Trade Bot Manager Telegram_\.\n\nThis telegram bot was developed by @lorcalhost\.\nFind out more about the project [here](https://github.com/lorcalhost/BTB-manager-telegram)\.' - reply_markup=ReplyKeyboardMarkup( - keyboard, - one_time_keyboard=True, - resize_keyboard=True - ) - update.message.reply_text( - message, - reply_markup=reply_markup, - parse_mode='MarkdownV2', - disable_web_page_preview=True - ) - return MENU - - def __menu(self, update: Update, _: CallbackContext) -> int: - self.logger.info(f'Menu selector. ({update.message.text})') - - keyboard = [ - ['⚠ Check bot status', '👛 Edit coin list'], - ['▶ Start trade bot', '⚙ Edit user.cfg'], - ['⏹ Stop trade bot', '❌ Delete database'], - ['📜 Read last log lines', '📈 Calculate gains'] - ] - reply_markup = ReplyKeyboardMarkup( - keyboard, - resize_keyboard=True - ) - - if update.message.text in ['Begin', 'Go back']: - message = 'Please select one of the options.' - update.message.reply_text( - message, - reply_markup=reply_markup - ) - - elif update.message.text == '⚠ Check bot status': - update.message.reply_text( - self.__btn_check_status(), - reply_markup=reply_markup - ) - - elif update.message.text == '👛 Edit coin list': - re = self.__btn_edit_coin() - if re[1]: - update.message.reply_text( - re[0], - reply_markup=ReplyKeyboardRemove(), - parse_mode='MarkdownV2' - ) - return EDIT_COIN_LIST - else: - update.message.reply_text( - re[0], - reply_markup=reply_markup, - parse_mode='MarkdownV2' - ) - - elif update.message.text == '▶ Start trade bot': - update.message.reply_text( - self.__btn_start_bot(), - reply_markup=reply_markup, - parse_mode='MarkdownV2' - ) - - elif update.message.text == '⏹ Stop trade bot': - update.message.reply_text( - self.__btn_stop_bot(), - reply_markup=reply_markup - ) - - elif update.message.text == '❌ Delete database': - update.message.reply_text( - self.__btn_delete_db(), - reply_markup=reply_markup, - parse_mode='MarkdownV2' - ) - - elif update.message.text == '⚙ Edit user.cfg': - re = self.__btn_edit_user_cfg() - if re[1]: - update.message.reply_text( - re[0], - reply_markup=ReplyKeyboardRemove(), - parse_mode='MarkdownV2' - ) - return EDIT_USER_CONFIG - else: - update.message.reply_text( - re[0], - reply_markup=reply_markup, - parse_mode='MarkdownV2' - ) - - elif update.message.text == '📜 Read last log lines': - update.message.reply_text( - self.__btn_read_log(), - reply_markup=reply_markup, - parse_mode='MarkdownV2' - ) - - return MENU - - def __edit_coin(self, update: Update, _: CallbackContext) -> int: - self.logger.info(f'Editing coin list. ({update.message.text})') - - message = f'✔ Successfully edited coin list file to:\n\n```\n{update.message.text}\n```'.replace('.', '\.') - coin_file_path = f'{self.root_path}supported_coin_list' - try: - copyfile(coin_file_path, f'{coin_file_path}.backup') - with open(coin_file_path, 'w') as f: - f.write(update.message.text + '\n') - except: - message = '❌ Unable to edit coin list file\.' - - keyboard = [['Go back']] - reply_markup = ReplyKeyboardMarkup( - keyboard, - resize_keyboard=True - ) - update.message.reply_text( - message, - reply_markup=reply_markup, - parse_mode='MarkdownV2' - ) - - return MENU - - def __edit_user_config(self, update: Update, _: CallbackContext) -> int: - self.logger.info(f'Editing user configuration. ({update.message.text})') - - message = f'✔ Successfully edited user configuration file to:\n\n```\n{update.message.text}\n```'.replace('.', '\.') - user_cfg_file_path = f'{self.root_path}user.cfg' - try: - copyfile(user_cfg_file_path, f'{user_cfg_file_path}.backup') - with open(user_cfg_file_path, 'w') as f: - f.write(update.message.text + '\n\n\n') - except: - message = '❌ Unable to edit user configuration file\.' - - keyboard = [['Go back']] - reply_markup = ReplyKeyboardMarkup( - keyboard, - resize_keyboard=True - ) - update.message.reply_text( - message, - reply_markup=reply_markup, - parse_mode='MarkdownV2' - ) - - return MENU - - @staticmethod - def __find_process(): - for p in psutil.process_iter(): - if 'binance_trade_bot' in p.name() or 'binance_trade_bot' in ' '.join(p.cmdline()): - return True - return False - - def __find_and_kill_process(self): - try: - for p in psutil.process_iter(): - if 'binance_trade_bot' in p.name() or 'binance_trade_bot' in ' '.join(p.cmdline()): - p.terminate() - p.wait() - except Exception as e: - self.logger.info(f'ERROR: {e}') - - def __btn_check_status(self): - self.logger.info('Check status button pressed.') - - message = '⚠ Binance Trade Bot is not running.' - if self.__find_process(): - message = '✔ Binance Trade Bot is running.' - return message - - def __btn_edit_coin(self): - self.logger.info('Edit coin list button pressed.') - - message = '⚠ Please stop Binance Trade Bot before editing the coin list\.' - edit = False - coin_file_path = f'{self.root_path}supported_coin_list' - if not self.__find_process(): - if os.path.exists(coin_file_path): - with open(coin_file_path) as f: - message = f'Current coin list is:\n\n```\n{f.read()}\n```\n\n_*Please reply with a message containing the updated coin list*_.'.replace('.', '\.') - edit = True - else: - message = f'❌ Unable to find coin list file at `{coin_file_path}`.'.replace('.', '\.') - return [message, edit] - - def __btn_start_bot(self): - self.logger.info('Start bot button pressed.') - - message = '⚠ Binance Trade Bot is already running\.' - if not self.__find_process(): - if os.path.exists(f'{self.root_path}binance_trade_bot/'): - subprocess.call('$(which python3) -m binance_trade_bot &', shell=True) - if not self.__find_process(): - message = '❌ Unable to start Binance Trade Bot\.' - else: - message = '✔ Binance Trade Bot successfully started\.' - else: - message = '❌ Unable to find _Binance Trade Bot_ installation in this directory\.\nMake sure the `BTBManagerTelegram.py` file is in the _Binance Trade Bot_ installation folder\.' - return message - - def __btn_stop_bot(self): - self.logger.info('Stop bot button pressed.') - - message = '⚠ Binance Trade Bot is not running.' - if self.__find_process(): - self.__find_and_kill_process() - if not self.__find_process(): - message = '✔ Successfully stopped the bot.' - else: - message = '❌ Unable to stop Binance Trade Bot.\n\nIf you are running the telegram bot on Windows make sure to run with administrator privileges.' - return message - - def __btn_delete_db(self): - self.logger.info('Delete database button pressed.') - - message = '⚠ Please stop Binance Trade Bot before deleting the database file\.' - db_file_path = f'{self.root_path}data/crypto_trading.db' - if not self.__find_process(): - if os.path.exists(db_file_path): - try: - copyfile(db_file_path, f'{db_file_path}.backup') - os.remove(db_file_path) - message = '✔ Successfully deleted database file\.' - except: - message = '❌ Unable to delete database file\.' - else: - message = f'⚠ Unable to find database file at `{db_file_path}`.'.replace('.', '\.') - return message - - def __btn_edit_user_cfg(self): - self.logger.info('Edit user configuration button pressed.') - - message = '⚠ Please stop Binance Trade Bot before editing user configuration file\.' - edit = False - user_cfg_file_path = f'{self.root_path}user.cfg' - if not self.__find_process(): - if os.path.exists(user_cfg_file_path): - with open(user_cfg_file_path) as f: - message = f'Current configuration file is:\n\n```\n{f.read()}\n```\n\n_*Please reply with a message containing the updated configuration*_.'.replace('.', '\.') - edit = True - else: - message = f'❌ Unable to find user configuration file at `{user_cfg_file_path}`.'.replace('.', '\.') - return [message, edit] - - def __btn_read_log(self): - self.logger.info('Read log button pressed.') - - log_file_path = f'{self.root_path}logs/crypto_trading.log' - message = f'❌ Unable to find log file at `{log_file_path}`.'.replace('.', '\.') - if os.path.exists(log_file_path): - with open(log_file_path) as f: - file_content = f.read().replace('.', '\.')[-4000:] - message = f'Last *4000* characters in log file:\n\n```\n{file_content}\n```' - return message - - def __cancel(self, update: Update, _: CallbackContext) -> int: - self.logger.info('Conversation canceled.') - - update.message.reply_text( - 'Bye! I hope we can talk again some day.', - reply_markup=ReplyKeyboardRemove() - ) - return ConversationHandler.END - - -if __name__ == '__main__': - BTBManagerTelegram() \ No newline at end of file +from btb_manager_telegram.__main__ import main, pre_run_main + +if __name__ == "__main__": + pre_run_main() + main() diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..02dfc2ab --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,10 @@ +# Contributing +Contributions are always welcome, no matter how large or small! + +# Formatting +Your code should be formatted using `Black` and `isort` +To format your code you can run the following commands: +```console +$ python3 -m black ./ +$ python3 -m isort ./ --settings-path ./.isort.cfg +``` diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..a3d250a8 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ + +################################################################### +# Dockerfile to build container image of: +# - BTB-manager-telegram +################################################################### + +FROM python:3 + +WORKDIR ./ + +############ Copying requirements.txt into the container ########## +COPY requirements.txt ./ + +#################### Installing dependencies ###################### +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +################## Running the main python Script ################# +CMD [ "python", "-m", "btb_manager_telegram" ] diff --git a/LICENSE b/LICENSE index b9591d25..73e8b5fe 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,21 @@ -MIT License - -Copyright (c) 2021 Lorenzo Callegari - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +MIT License + +Copyright (c) 2021 Lorenzo Callegari + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 7f0a8a72..465d9428 100644 --- a/README.md +++ b/README.md @@ -1,87 +1,182 @@ -# Binance Trade Bot Manager Telegram -A Telegram bot for remotely managing [Binance Trade Bot]. - -**If you have feature requests please open an issue on this repo, developers are also welcome to contribute!** - -## About -I wanted to develop an easy way of managing [Binance Trade Bot] so that I wouldn't have to constantly ssh into the VPS and my non-techy friends could enjoy the benefits of the bot. - -As of now the bot is able to perform the following actions: -- Check bot status (running / not running) -- Start *Binance Trade Bot* -- Stop *Binance Trade Bot* -- Edit coin list (`supported_coin_list` file) -- Edit user configuration (`user.cfg` file) -- Delete database file (`crypto_trading.db` file) -- Display last 4000 characters of log file -- 👷🏻‍♂️ WIP Display gains / current ratios in the database - -The program's default behavior fetches Telegram `token` and `user_id` from [Binance Trade Bot]'s `apprise.yaml` file. -Only the Telegram user with `user_id` equal to the one set in the `apprise.yaml` file will be able to use the bot. - -⚠ The program is fully compatible with **Linux** and **Windows** through **[WSL]**, further compatibility testing needs to be done for **native Windows** and **MacOS**. -## Installation -*Python 3* is required. -1. Install dependencies: -```console -# install required Python 3 modules -$ python3 -m pip install -r requirements.txt -``` -2. Move `BTBManagerTelegram.py` file into [Binance Trade Bot]'s installation folder (it should be in the same folder as `supported_coin_list` file) - -⚠ Make sure the correct `rwx` permissions are set and the program is run with correct privileges. - -## Usage -### **Method 1**: Run directly -**BTBManagerTelegram** can be run directly by executing the following command: -```console -# Run normally -$ python3 BTBManagerTelegram.py - -# If the bot is running on a server you may want to keep it running even after ssh connection is closed by using nohup -$ nohup python3 BTBManagerTelegram.py & -``` -Make sure [Binance Trade Bot]'s `apprise.yaml` file is correctly setup before running. -### **Method 2:** Import script -**BTBManagerTelegram** can be imported in your Python script and used in the following way: -```python -from BTBManagerTelegram import BTBManagerTelegram -BTBManagerTelegram() -``` -The `BTBManagerTelegram()` class takes the following ***optional*** initialization arguments: -- `root_path`: -*Default value*: `'./'` -*Description*: Current base directory, to be used in case the bot has not been put inside [Binance Trade Bot]'s installation folder. -- `from_yaml`: -*Default value*: `True` -*Description*: Set to `False` if you **don't** want *BTBManagerTelegram* to automatically fetch Telegram `token` and `user_id` from `apprise.yaml` file. -- `token`: -*Default value*: `None` -*Description*: If `from_yaml` is set to `False` this will be used as Telegram `token`. -- `user_id`: -*Default value*: `None` -*Description*: If `from_yaml` is set to `False` this will be used as Telegram `user_id`. - -## Interaction -Interaction with **BTBManagerTelegram** can be *started* by sending the command `/start` in the bot's Telegram chat. -## Disclaimer - -This project is for informational purposes only. You should not construe any -such information or other material as legal, tax, investment, financial, or -other advice. Nothing contained here constitutes a solicitation, recommendation, -endorsement, or offer by me or any third party service provider to buy or sell -any securities or other financial instruments in this or in any other -jurisdiction in which such solicitation or offer would be unlawful under the -securities laws of such jurisdiction. - -If you plan to use real money, USE AT YOUR OWN RISK. - -Under no circumstances will I be held responsible or liable in any way for any -claims, damages, losses, expenses, costs, or liabilities whatsoever, including, -without limitation, any direct or indirect damages for loss of profits. - -##### ⚙ Developed for the love of task automation by [Lorenzo Callegari](https://github.com/lorcalhost) - - -[Binance Trade Bot]: https://github.com/edeng23/binance-trade-bot -[WSL]: https://docs.microsoft.com/en-us/windows/wsl/install-win10 +# Binance Trade Bot Manager Telegram + +A Telegram bot for remotely managing [Binance Trade Bot]. + +**If you have feature requests please open an issue on this repo, developers are also welcome to contribute!** + +## About + +I wanted to develop an easy way of managing [Binance Trade Bot] so that I wouldn't have to constantly ssh into my VPS, and my non-techy friends could enjoy the benefits of automated trading. + +As of now the bot is able to perform the following actions: + +- Check bot status (running / not running) +- Start _Binance Trade Bot_ +- Stop _Binance Trade Bot_ +- Display current coin stats (balance, USD value, BTC value, initial buying price) +- Display current coin ratios +- Display progress (how much more of a certain coin you gained since you started using _Binance Trade Bot_) +- Display trade history +- Display last 4000 characters of log file +- Edit coin list (`supported_coin_list` file) +- Edit user configuration (`user.cfg` file) +- Delete database file (`crypto_trading.db` file) +- Export database file +- **Update** _Binance Trade Bot_ (and notify when new update is available) +- **Update** _Binance Trade Bot Manager Telegram_ (and notify when new update is available) + +The program's default behavior fetches Telegram `token` and `user_id` from [Binance Trade Bot]'s `apprise.yml` file. +Only the Telegram user with `user_id` equal to the one set in the `apprise.yml` file will be able to use the bot. + +⚠ The program is fully compatible with **Linux** and **Windows** through **[WSL]**, further compatibility testing needs to be done for **native Windows** and **MacOS**. + +## Installation + +_Python 3_ is required. +**BTB-manager-telegram** should be installed in the same parent directory as _Binance Trade Bot_. +Your filesystem should look like this: + +``` +. +└── *parent_dir* + ├── BTB-manager-telegram + └── binance-trade-bot +``` + +1. Clone this repository: + +```console +$ git clone https://github.com/lorcalhost/BTB-manager-telegram.git +``` + +2. Move to `BTB-manager-telegram`'s directory: + +```console +$ cd BTB-manager-telegram +``` + +3. Install `BTB-manager-telegram`'s dependencies: + +```console +$ python3 -m pip install -r requirements.txt +``` + +⚠ Make sure the correct `rwx` permissions are set and the program is run with correct privileges. + +## Usage + +**BTBManagerTelegram** can be run directly by executing the following command: + +```console +# Run normally +$ python3 -m btb_manager_telegram + +# If the bot is running on a server you may want to keep it running even after ssh connection is closed by using nohup +$ nohup python3 -m btb_manager_telegram & +``` + +Make sure [Binance Trade Bot]'s `apprise.yml` file is correctly setup before running. +
+Note: +If _Binance Trade Bot_ and _BTB-Manager-Telegram_ were **not** installed in the same parent directory or you want to use different `token` and `user_id` from the ones in the `apprise.yml` file, the following optional arguments can be used: + +```console +optional arguments: + -p PATH, --path PATH (optional) binance-trade-bot installation absolute path + -t TOKEN, --token TOKEN + (optional) Telegram bot token + -u USER_ID, --user_id USER_ID + (optional) Telegram user id +``` + +## Interaction + +Interaction with **BTBManagerTelegram** can be _started_ by sending the `/start` command in the bot's Telegram chat. +Every time the Telegram bot is restarted the `/start` command should be sent again. + +## Screenshots + +
CLICK ME + +

+      + +

+
+
+ +## Troubleshooting + +### 1. I am sending the `/start` command to the bot but it's not answering: + +
CLICK ME + +

+ +Usually when this happens it means that you haven't properly setup your `apprise.yml` file. +For security reasons the bot is programmed so that it only responds to the person with `user_id` equal to the one set in the Telegram URL inside the `apprise.yml` file. + +Example of `apprise.yml` file: + +```yaml +version: 1 +urls: + - tgram://123456789:AABx8iXjE5C-vG4SDhf6ARgdFgxYxhuHb4A/606743502 +``` + +In this URL: + +- `123456789:AABx8iXjE5C-vG4SDhf6ARgdFgxYxhuHb4A` is the bot's `token` +- `606743502` is the `user_id` + +You can find your `user_id` by sending a Telegram message to [@userinfobot](https://t.me/userinfobot). + +Note: +If the bot is not responsive after using the _Update Telegram Bot_ function something might have gone wrong and you need to manually restart _BTB Manager Telegram_. + +

+
+
+ +### 2. ERROR: `Make sure that only one bot instance is running`: + +
CLICK ME + +

+ +This means that there are two or more instances of `BTB-Manager-Telegram` running at the same time on the same Telegram `token`. +To fix this error you can kill all `BTB-Manager-Telegram` instances and restart the Telegram bot. +You can kill the processes using the following command: + +```bash +kill -9 $(ps ax | grep btb_manager_telegram | fgrep -v grep | awk '{ print $1 }') +``` + +

+
+
+ +## Support the Project + + + +## Disclaimer + +This project is for informational purposes only. You should not consider any +such information or other material as legal, tax, investment, financial, or +other advice. Nothing contained here constitutes a solicitation, recommendation, +endorsement, or offer by me or any third party service provider to buy or sell +any securities or other financial instruments in this or in any other +jurisdiction in which such solicitation or offer would be unlawful under the +securities laws of such jurisdiction. + +If you plan to use real money, USE AT YOUR OWN RISK. + +Under no circumstances will I or the project's maintainers be held responsible or liable in any way for any claims, +damages, losses, expenses, costs, or liabilities whatsoever, including, without limitation, any direct or indirect +damages for loss of profits. + +##### ⚙ Developed for love of task automation by [Lorenzo Callegari](https://github.com/lorcalhost) + +[binance trade bot]: https://github.com/edeng23/binance-trade-bot +[wsl]: https://docs.microsoft.com/en-us/windows/wsl/install-win10 diff --git a/btb_manager_telegram/__init__.py b/btb_manager_telegram/__init__.py new file mode 100644 index 00000000..35fc8f67 --- /dev/null +++ b/btb_manager_telegram/__init__.py @@ -0,0 +1,11 @@ +import logging +import sched +import time + +MENU, EDIT_COIN_LIST, EDIT_USER_CONFIG, DELETE_DB, UPDATE_TG, UPDATE_BTB = range(6) +logging.basicConfig( + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", level=logging.INFO +) +logger = logging.getLogger("btb_manager_telegram_logger") + +scheduler = sched.scheduler(time.time, time.sleep) diff --git a/btb_manager_telegram/__main__.py b/btb_manager_telegram/__main__.py new file mode 100644 index 00000000..f62a1f4d --- /dev/null +++ b/btb_manager_telegram/__main__.py @@ -0,0 +1,116 @@ +import argparse +import time +import sys +import os + +from telegram.ext import ConversationHandler, Updater + +from btb_manager_telegram import ( + DELETE_DB, + EDIT_COIN_LIST, + EDIT_USER_CONFIG, + MENU, + UPDATE_BTB, + UPDATE_TG, + scheduler, + settings, +) +from btb_manager_telegram.utils import ( + setup_root_path_constant, + setup_telegram_constants, + update_checker, +) + + +def pre_run_main() -> bool: + parser = argparse.ArgumentParser( + description="Thanks for using Binance Trade Bot Manager Telegram. " + 'By default the program will use "../binance-trade-bot/" as binance-trade-bot installation path.' + ) + parser.add_argument( + "-p", + "--path", + type=str, + help="(optional) binance-trade-bot installation absolute path", + default="../binance-trade-bot/", + ) + parser.add_argument( + "-t", "--token", type=str, help="(optional) Telegram bot token", default=None + ) + parser.add_argument( + "-u", "--user_id", type=str, help="(optional) Telegram user id", default=None + ) + parser.add_argument( + "-d", "--docker", action="store_true", + help="(optional) Run the script in a docker container." + "NOTE: Run the 'docker_setup.py' file before passing this flag." + ) + + args = parser.parse_args() + + if args.docker: + return True + + settings.ROOT_PATH = args.path + settings.TOKEN = args.token + settings.USER_ID = args.user_id + + setup_root_path_constant() + + if settings.TOKEN is None or settings.USER_ID is None: + setup_telegram_constants() + + # Setup update notifications scheduler + scheduler.enter(1, 1, update_checker) + time.sleep(1) + scheduler.run(blocking=False) + + return False + + +def main() -> None: + from btb_manager_telegram import handlers + + """Start the bot.""" + # Create the Updater and pass it your token + updater = Updater(settings.TOKEN) + + # Get the dispatcher to register handlers + dispatcher = updater.dispatcher + + conv_handler = ConversationHandler( + entry_points=[ + handlers.ENTRY_POINT_HANDLER, + ], + states={ + MENU: [handlers.MENU_HANDLER], + EDIT_COIN_LIST: [handlers.EDIT_COIN_LIST_HANDLER], + EDIT_USER_CONFIG: [handlers.EDIT_USER_CONFIG_HANDLER], + DELETE_DB: [handlers.DELETE_DB_HANDLER], + UPDATE_TG: [handlers.UPDATE_TG_HANDLER], + UPDATE_BTB: [handlers.UPDATE_BTB_HANDLER], + }, + fallbacks=[handlers.FALLBACK_HANDLER], + per_user=True, + ) + dispatcher.add_handler(conv_handler) + + # Start the Bot + updater.start_polling() + + # Run the bot until you press Ctrl-C or the process receives SIGINT, + # SIGTERM or SIGABRT. This should be used most of the time, since + # start_polling() is non-blocking and will stop the bot gracefully. + updater.idle() + + +if __name__ == "__main__": + on_docker = pre_run_main() + if on_docker: + os.system("docker build --no-cache -t py-container .") + try: os.system("docker run --rm -it py-container") + except Exception: pass + os.system("docker rmi -f py-container") + sys.exit(-1) + + main() diff --git a/btb_manager_telegram/buttons.py b/btb_manager_telegram/buttons.py new file mode 100644 index 00000000..988392f2 --- /dev/null +++ b/btb_manager_telegram/buttons.py @@ -0,0 +1,426 @@ +import os +import sqlite3 +import subprocess +from configparser import ConfigParser +from datetime import datetime + +from btb_manager_telegram import logger, settings +from btb_manager_telegram.utils import ( + find_and_kill_process, + find_process, + is_btb_bot_update_available, + is_tg_bot_update_available, + text_4096_cutter, +) + + +def current_value(): + logger.info("Current value button pressed.") + + db_file_path = f"{settings.ROOT_PATH}data/crypto_trading.db" + message = [f"⚠ Unable to find database file at `{db_file_path}`\."] + if os.path.exists(db_file_path): + try: + con = sqlite3.connect(db_file_path) + cur = con.cursor() + + # Get current coin symbol, bridge symbol, order state, order size, initial buying price + try: + cur.execute( + """SELECT alt_coin_id, crypto_coin_id, state, crypto_starting_balance, crypto_trade_amount FROM trade_history ORDER BY datetime DESC LIMIT 1;""" + ) + current_coin, bridge, state, order_size, buy_price = cur.fetchone() + if current_coin is None: + raise Exception() + if state == "ORDERED": + return [ + f"A buy order of `{round(order_size, 2)}` *{bridge}* is currently placed on coin *{current_coin}*.\n\n" + f"_Waiting for buy order to complete_.".replace(".", "\.") + ] + except Exception: + con.close() + return ["❌ Unable to fetch current coin from database\."] + + # Get balance, current coin price in USD, current coin price in BTC + try: + cur.execute( + f"""SELECT balance, usd_price, btc_price, datetime FROM 'coin_value' WHERE coin_id = '{current_coin}' ORDER BY datetime DESC LIMIT 1;""" + ) + query = cur.fetchone() + if query is None: + return [ + f"❌ No information about *{current_coin}* available in the database\.", + "⚠ If you tried using the `Current value` button during a trade please try again after the trade has been completed\.", + ] + balance, usd_price, btc_price, last_update = query + if balance is None: + balance = 0 + if usd_price is None: + usd_price = 0 + if btc_price is None: + btc_price = 0 + last_update = datetime.strptime(last_update, "%Y-%m-%d %H:%M:%S.%f") + except Exception: + con.close() + return [ + "❌ Unable to fetch current coin information from database\.", + "⚠ If you tried using the `Current value` button during a trade please try again after the trade has been completed\.", + ] + + # Generate message + try: + m_list = [ + f"\nLast update: `{last_update.strftime('%H:%M:%S %d/%m/%Y')}`\n\n" + f"*Current coin {current_coin}:*\n" + f"\t\- Balance: `{round(balance, 6)}` *{current_coin}*\n" + f"\t\- Current coin exchange ratio: `{round(usd_price, 4)}` *USD*/*{current_coin}*\n" + f"\t\- Value in *USD*: `{round((balance * usd_price), 2)}` *USD*\n" + f"\t\- Value in *BTC*: `{round((balance * btc_price), 6)}` *BTC*\n\n" + f"_Initially bought for_ {round(buy_price, 2)} *{bridge}*\n" + f"_Exchange ratio when purchased:_ `{round((buy_price / balance), 6)}` *{bridge}*/*{current_coin}*".replace( + ".", "\." + ) + ] + message = text_4096_cutter(m_list) + con.close() + except Exception: + con.close() + return [ + "❌ Something went wrong, unable to generate value at this time\." + ] + except Exception: + message = ["❌ Unable to perform actions on the database\."] + return message + + +def check_progress(): + logger.info("Progress button pressed.") + + db_file_path = f"{settings.ROOT_PATH}data/crypto_trading.db" + message = [f"⚠ Unable to find database file at `{db_file_path}`\."] + if os.path.exists(db_file_path): + try: + con = sqlite3.connect(db_file_path) + cur = con.cursor() + + # Get progress information + try: + cur.execute( + """SELECT th1.alt_coin_id AS coin, th1.alt_trade_amount AS amount, th1.crypto_trade_amount AS priceInUSD,(th1.alt_trade_amount - ( SELECT th2.alt_trade_amount FROM trade_history th2 WHERE th2.alt_coin_id = th1.alt_coin_id AND th1.datetime > th2.datetime AND th2.selling = 0 ORDER BY th2.datetime DESC LIMIT 1)) AS change, datetime FROM trade_history th1 WHERE th1.state = 'COMPLETE' AND th1.selling = 0 ORDER BY th1.datetime DESC LIMIT 15""" + ) + query = cur.fetchall() + + # Generate message + m_list = ["Current coin amount progress:\n\n"] + for coin in query: + last_trade_date = datetime.strptime( + coin[4], "%Y-%m-%d %H:%M:%S.%f" + ).strftime("%H:%M:%S %d/%m/%Y") + m_list.append( + f"*{coin[0]}*\n" + f"\t\- Amount: `{round(coin[1], 6)}` *{coin[0]}*\n" + f"\t\- Price: `{round(coin[2], 2)}` *USD*\n" + f"\t\- Change: {f'`{round(coin[3], 2)}` *{coin[0]}*' if coin[3] is not None else f'`{coin[3]}`'}\n" + f"\t\- Last trade: `{last_trade_date}`\n\n".replace(".", "\.") + ) + + message = text_4096_cutter(m_list) + con.close() + except Exception: + con.close() + return ["❌ Unable to fetch progress information from database\."] + except Exception: + message = ["❌ Unable to perform actions on the database\."] + return message + + +def current_ratios(): + logger.info("Current ratios button pressed.") + + db_file_path = f"{settings.ROOT_PATH}data/crypto_trading.db" + user_cfg_file_path = f"{settings.ROOT_PATH}user.cfg" + message = [f"⚠ Unable to find database file at `{db_file_path}`\."] + if os.path.exists(db_file_path): + try: + # Get bridge currency symbol + with open(user_cfg_file_path) as cfg: + config = ConfigParser() + config.read_file(cfg) + bridge = config.get("binance_user_config", "bridge") + + con = sqlite3.connect(db_file_path) + cur = con.cursor() + + # Get current coin symbol + try: + cur.execute( + """SELECT alt_coin_id FROM trade_history ORDER BY datetime DESC LIMIT 1;""" + ) + current_coin = cur.fetchone()[0] + if current_coin is None: + raise Exception() + except Exception: + con.close() + return ["❌ Unable to fetch current coin from database\."] + + # Get prices and ratios of all alt coins + try: + cur.execute( + f"""SELECT sh.datetime, p.to_coin_id, sh.other_coin_price, ( ( ( current_coin_price / other_coin_price ) - 0.001 * 5 * ( current_coin_price / other_coin_price ) ) - sh.target_ratio ) AS 'ratio_dict' FROM scout_history sh JOIN pairs p ON p.id = sh.pair_id WHERE p.from_coin_id='{current_coin}' AND p.from_coin_id = ( SELECT alt_coin_id FROM trade_history ORDER BY datetime DESC LIMIT 1) ORDER BY sh.datetime DESC LIMIT ( SELECT count(DISTINCT pairs.to_coin_id) FROM pairs WHERE pairs.from_coin_id='{current_coin}');""" + ) + query = cur.fetchall() + + # Generate message + last_update = datetime.strptime(query[0][0], "%Y-%m-%d %H:%M:%S.%f") + query = sorted(query, key=lambda k: k[-1], reverse=True) + + m_list = [ + f"\nLast update: `{last_update.strftime('%H:%M:%S %d/%m/%Y')}`\n\n" + f"*Coin ratios compared to {current_coin}:*\n".replace(".", "\.") + ] + for coin in query: + m_list.append( + f"*{coin[1]}*:\n" + f"\t\- Price: `{coin[2]}` {bridge}\n" + f"\t\- Ratio: `{round(coin[3], 6)}`\n\n".replace(".", "\.") + ) + + message = text_4096_cutter(m_list) + con.close() + except Exception: + con.close() + return [ + "❌ Something went wrong, unable to generate ratios at this time\." + ] + except Exception: + message = ["❌ Unable to perform actions on the database\."] + return message + + +def check_status(): + logger.info("Check status button pressed.") + + message = "⚠ Binance Trade Bot is not running." + if find_process(): + message = "✔ Binance Trade Bot is running." + return message + + +def trade_history(): + logger.info("Trade history button pressed.") + + db_file_path = f"{settings.ROOT_PATH}data/crypto_trading.db" + message = [f"⚠ Unable to find database file at `{db_file_path}`\."] + if os.path.exists(db_file_path): + try: + con = sqlite3.connect(db_file_path) + cur = con.cursor() + + # Get last 10 trades + try: + cur.execute( + """SELECT alt_coin_id, crypto_coin_id, selling, state, alt_trade_amount, crypto_trade_amount, datetime FROM trade_history ORDER BY datetime DESC LIMIT 10;""" + ) + query = cur.fetchall() + + m_list = [ + f"Last **{10 if len(query) > 10 else len(query)}** trades:\n\n" + ] + for trade in query: + if trade[4] is None: + continue + date = datetime.strptime(trade[6], "%Y-%m-%d %H:%M:%S.%f") + m_list.append( + f"`{date.strftime('%H:%M:%S %d/%m/%Y')}`\n" + f"*{'Sold' if trade[2] else 'Bought'}* `{round(trade[4], 6)}` *{trade[0]}*{f' for `{round(trade[5], 2)}` *{trade[1]}*' if trade[5] is not None else ''}\n" + f"Status: _*{trade[3]}*_\n\n".replace(".", "\.") + ) + + message = text_4096_cutter(m_list) + con.close() + except Exception: + con.close() + return [ + "❌ Something went wrong, unable to generate trade history at this time\." + ] + except Exception: + message = ["❌ Unable to perform actions on the database\."] + return message + + +def start_bot(): + logger.info("Start bot button pressed.") + + message = "⚠ Binance Trade Bot is already running\." + if not find_process(): + if os.path.exists(f"{settings.ROOT_PATH}binance_trade_bot/"): + subprocess.call( + f"cd {settings.ROOT_PATH} && $(which python3) -m binance_trade_bot &", + shell=True, + ) + if not find_process(): + message = "❌ Unable to start Binance Trade Bot\." + else: + message = "✔ Binance Trade Bot successfully started\." + else: + message = ( + f"❌ Unable to find _Binance Trade Bot_ installation at {settings.ROOT_PATH}\.\n" + f"Make sure the `binance-trade-bot` and `BTB-manager-telegram` are in the same parent directory\." + ) + return message + + +def stop_bot(): + logger.info("Stop bot button pressed.") + + message = "⚠ Binance Trade Bot is not running." + if find_process(): + find_and_kill_process() + if not find_process(): + message = "✔ Successfully stopped the bot." + else: + message = ( + "❌ Unable to stop Binance Trade Bot.\n\n" + "If you are running the telegram bot on Windows make sure to run with administrator privileges." + ) + return message + + +def read_log(): + logger.info("Read log button pressed.") + + log_file_path = f"{settings.ROOT_PATH}logs/crypto_trading.log" + message = f"❌ Unable to find log file at `{log_file_path}`.".replace(".", "\.") + if os.path.exists(log_file_path): + with open(log_file_path) as f: + file_content = f.read().replace(".", "\.")[-4000:] + message = ( + f"Last *4000* characters in log file:\n\n" + f"```\n" + f"{file_content}\n" + f"```" + ) + return message + + +def delete_db(): + logger.info("Delete database button pressed.") + + message = "⚠ Please stop Binance Trade Bot before deleting the database file\." + delete = False + db_file_path = f"{settings.ROOT_PATH}data/crypto_trading.db" + if not find_process(): + if os.path.exists(db_file_path): + message = "Are you sure you want to delete the database file?" + delete = True + else: + message = f"⚠ Unable to find database file at `{db_file_path}`.".replace( + ".", "\." + ) + return [message, delete] + + +def edit_user_cfg(): + logger.info("Edit user configuration button pressed.") + + message = "⚠ Please stop Binance Trade Bot before editing user configuration file\." + edit = False + user_cfg_file_path = f"{settings.ROOT_PATH}user.cfg" + if not find_process(): + if os.path.exists(user_cfg_file_path): + with open(user_cfg_file_path) as f: + message = ( + f"Current configuration file is:\n\n" + f"```\n" + f"{f.read()}\n" + f"```\n\n" + f"_*Please reply with a message containing the updated configuration*_.\n\n" + f"Write /stop to stop editing and exit without changes.".replace( + ".", "\." + ) + ) + edit = True + else: + message = f"❌ Unable to find user configuration file at `{user_cfg_file_path}`.".replace( + ".", "\." + ) + return [message, edit] + + +def edit_coin(): + logger.info("Edit coin list button pressed.") + + message = "⚠ Please stop Binance Trade Bot before editing the coin list\." + edit = False + coin_file_path = f"{settings.ROOT_PATH}supported_coin_list" + if not find_process(): + if os.path.exists(coin_file_path): + with open(coin_file_path) as f: + message = ( + f"Current coin list is:\n\n" + f"```\n{f.read()}\n```\n\n" + f"_*Please reply with a message containing the updated coin list*_.\n\n" + f"Write /stop to stop editing and exit without changes.".replace( + ".", "\." + ) + ) + edit = True + else: + message = f"❌ Unable to find coin list file at `{coin_file_path}`.".replace( + ".", "\." + ) + return [message, edit] + + +def export_db(): + logger.info("Export database button pressed.") + + message = "⚠ Please stop Binance Trade Bot before exporting the database file\." + db_file_path = f"{settings.ROOT_PATH}data/crypto_trading.db" + fil = None + if not find_process(): + if os.path.exists(db_file_path): + with open(db_file_path, "rb") as db: + fil = db.read() + message = "Here is your database file:" + else: + message = "❌ Unable to Export the database file\." + return [message, fil] + + +def update_tg_bot(): + logger.info("Update Telegram bot button pressed.") + + message = "Your BTB Manager Telegram installation is already up to date\." + upd = False + to_update = is_tg_bot_update_available() + if to_update is not None: + if to_update: + message = ( + "An update for BTB Manager Telegram is available\.\n" + "Would you like to update now?" + ) + upd = True + else: + message = ( + "Error while trying to fetch BTB Manager Telegram version information\." + ) + return [message, upd] + + +def update_btb(): + logger.info("Update Binance Trade Bot button pressed.") + + message = "Your Binance Trade Bot installation is already up to date\." + upd = False + to_update = is_btb_bot_update_available() + if to_update is not None: + if to_update: + upd = True + message = ( + "An update for Binance Trade Bot is available\.\n" + "Would you like to update now?" + ) + else: + message = "Error while trying to fetch Binance Trade Bot version information\." + return [message, upd] diff --git a/btb_manager_telegram/handlers.py b/btb_manager_telegram/handlers.py new file mode 100644 index 00000000..5ac2130e --- /dev/null +++ b/btb_manager_telegram/handlers.py @@ -0,0 +1,427 @@ +import os +import subprocess +from shutil import copyfile + +from telegram import Bot, ReplyKeyboardMarkup, ReplyKeyboardRemove, Update +from telegram.ext import ( + CallbackContext, + CommandHandler, + ConversationHandler, + Filters, + MessageHandler, +) + +from btb_manager_telegram import ( + DELETE_DB, + EDIT_COIN_LIST, + EDIT_USER_CONFIG, + MENU, + UPDATE_BTB, + UPDATE_TG, + buttons, + logger, + settings, +) +from btb_manager_telegram.utils import find_and_kill_process + + +def menu(update: Update, _: CallbackContext) -> int: + logger.info(f"Menu selector. ({update.message.text})") + + keyboard = [ + [ + "💵 Current value", + ], + ["📈 Progress", "➗ Current ratios"], + ["🔍 Check bot status", "⌛ Trade History"], + ["🛠 Maintenance", "⚙️ Configurations"], + ] + + config_keyboard = [ + ["▶ Start trade bot", "⏹ Stop trade bot"], + ["📜 Read last log lines", "❌ Delete database"], + ["⚙ Edit user.cfg", "👛 Edit coin list"], + ["📤 Export database", "⬅️ Back"], + ] + + maintenance_keyboard = [ + ["Update Telegram Bot"], + ["Update Binance Trade Bot"], + ["⬅️ Back"], + ] + + reply_markup = ReplyKeyboardMarkup(keyboard, resize_keyboard=True) + + reply_markup_config = ReplyKeyboardMarkup(config_keyboard, resize_keyboard=True) + + reply_markup_maintenance = ReplyKeyboardMarkup( + maintenance_keyboard, resize_keyboard=True + ) + + if update.message.text in ["Begin", "⬅️ Back"]: + message = "Please select one of the options." + update.message.reply_text(message, reply_markup=reply_markup) + + elif update.message.text in ["Go back", "OK", "⚙️ Configurations"]: + message = "Please select one of the options." + update.message.reply_text(message, reply_markup=reply_markup_config) + + elif update.message.text in ["🛠 Maintenance", "Cancel update", "OK 👌"]: + message = "Please select one of the options." + update.message.reply_text(message, reply_markup=reply_markup_maintenance) + + elif update.message.text == "💵 Current value": + for mes in buttons.current_value(): + update.message.reply_text( + mes, reply_markup=reply_markup, parse_mode="MarkdownV2" + ) + + elif update.message.text == "📈 Progress": + for mes in buttons.check_progress(): + update.message.reply_text( + mes, reply_markup=reply_markup, parse_mode="MarkdownV2" + ) + + elif update.message.text == "➗ Current ratios": + for mes in buttons.current_ratios(): + update.message.reply_text( + mes, reply_markup=reply_markup, parse_mode="MarkdownV2" + ) + + elif update.message.text == "🔍 Check bot status": + update.message.reply_text(buttons.check_status(), reply_markup=reply_markup) + + elif update.message.text == "⌛ Trade History": + for mes in buttons.trade_history(): + update.message.reply_text( + mes, reply_markup=reply_markup, parse_mode="MarkdownV2" + ) + + elif update.message.text == "▶ Start trade bot": + update.message.reply_text( + buttons.start_bot(), + reply_markup=reply_markup_config, + parse_mode="MarkdownV2", + ) + + elif update.message.text == "⏹ Stop trade bot": + update.message.reply_text(buttons.stop_bot(), reply_markup=reply_markup_config) + + elif update.message.text == "📜 Read last log lines": + update.message.reply_text( + buttons.read_log(), + reply_markup=reply_markup_config, + parse_mode="MarkdownV2", + ) + + elif update.message.text == "❌ Delete database": + re = buttons.delete_db() + if re[1]: + kb = [["⚠ Confirm", "Go back"]] + update.message.reply_text( + re[0], + reply_markup=ReplyKeyboardMarkup(kb, resize_keyboard=True), + parse_mode="MarkdownV2", + ) + return DELETE_DB + else: + update.message.reply_text( + re[0], reply_markup=reply_markup_config, parse_mode="MarkdownV2" + ) + + elif update.message.text == "⚙ Edit user.cfg": + re = buttons.edit_user_cfg() + if re[1]: + update.message.reply_text( + re[0], reply_markup=ReplyKeyboardRemove(), parse_mode="MarkdownV2" + ) + return EDIT_USER_CONFIG + else: + update.message.reply_text( + re[0], reply_markup=reply_markup_config, parse_mode="MarkdownV2" + ) + + elif update.message.text == "👛 Edit coin list": + re = buttons.edit_coin() + if re[1]: + update.message.reply_text( + re[0], reply_markup=ReplyKeyboardRemove(), parse_mode="MarkdownV2" + ) + return EDIT_COIN_LIST + else: + update.message.reply_text( + re[0], reply_markup=reply_markup_config, parse_mode="MarkdownV2" + ) + + elif update.message.text == "📤 Export database": + re = buttons.export_db() + update.message.reply_text( + re[0], reply_markup=reply_markup_config, parse_mode="MarkdownV2" + ) + if re[1] is not None: + bot = Bot(settings.TOKEN) + bot.send_document( + chat_id=update.message.chat_id, + document=re[1], + filename="crypto_trading.db", + ) + + elif update.message.text == "Update Telegram Bot": + re = buttons.update_tg_bot() + if re[1]: + kb = [["Update", "Cancel update"]] + update.message.reply_text( + re[0], + reply_markup=ReplyKeyboardMarkup(kb, resize_keyboard=True), + parse_mode="MarkdownV2", + ) + return UPDATE_TG + else: + update.message.reply_text( + re[0], + reply_markup=reply_markup_maintenance, + parse_mode="MarkdownV2", + ) + + elif update.message.text == "Update Binance Trade Bot": + re = buttons.update_btb() + if re[1]: + kb = [["Update", "Cancel update"]] + update.message.reply_text( + re[0], + reply_markup=ReplyKeyboardMarkup(kb, resize_keyboard=True), + parse_mode="MarkdownV2", + ) + return UPDATE_BTB + else: + update.message.reply_text( + re[0], + reply_markup=reply_markup_maintenance, + parse_mode="MarkdownV2", + ) + + return MENU + + +def start(update: Update, _: CallbackContext) -> int: + logger.info("Started conversation.") + + keyboard = [["Begin"]] + message = ( + f"Hi *{update.message.from_user.first_name}*\!\n" + f"Welcome to _Binace Trade Bot Manager Telegram_\.\n\n" + f"This Telegram bot was developed by @lorcalhost\.\n" + f"Find out more about the project [here](https://github.com/lorcalhost/BTB-manager-telegram)\.\n\n" + f"If you like the bot please [consider supporting the project 🍻](https://www.buymeacoffee.com/lorcalhost)\." + ) + reply_markup = ReplyKeyboardMarkup( + keyboard, one_time_keyboard=True, resize_keyboard=True + ) + update.message.reply_text( + message, + reply_markup=reply_markup, + parse_mode="MarkdownV2", + disable_web_page_preview=True, + ) + return MENU + + +def edit_coin(update: Update, _: CallbackContext) -> int: + logger.info(f"Editing coin list. ({update.message.text})") + + if update.message.text != "/stop": + message = ( + f"✔ Successfully edited coin list file to:\n\n" + f"```\n" + f"{update.message.text}\n" + f"```".replace(".", "\.") + ) + coin_file_path = f"{settings.ROOT_PATH}supported_coin_list" + try: + copyfile(coin_file_path, f"{coin_file_path}.backup") + with open(coin_file_path, "w") as f: + f.write(update.message.text + "\n") + except Exception: + message = "❌ Unable to edit coin list file\." + else: + message = "👌 Exited without changes\.\nYour `supported_coin_list` file was *not* modified\." + + keyboard = [["Go back"]] + reply_markup = ReplyKeyboardMarkup(keyboard, resize_keyboard=True) + update.message.reply_text( + message, reply_markup=reply_markup, parse_mode="MarkdownV2" + ) + + return MENU + + +def edit_user_config(update: Update, _: CallbackContext) -> int: + logger.info(f"Editing user configuration. ({update.message.text})") + + if update.message.text != "/stop": + message = ( + f"✔ Successfully edited user configuration file to:\n\n" + f"```\n" + f"{update.message.text}\n" + f"```".replace(".", "\.") + ) + user_cfg_file_path = f"{settings.ROOT_PATH}user.cfg" + try: + copyfile(user_cfg_file_path, f"{user_cfg_file_path}.backup") + with open(user_cfg_file_path, "w") as f: + f.write(update.message.text + "\n\n\n") + except Exception: + message = "❌ Unable to edit user configuration file\." + else: + message = ( + "👌 Exited without changes\.\n" "Your `user.cfg` file was *not* modified\." + ) + + keyboard = [["Go back"]] + reply_markup = ReplyKeyboardMarkup(keyboard, resize_keyboard=True) + update.message.reply_text( + message, reply_markup=reply_markup, parse_mode="MarkdownV2" + ) + + return MENU + + +def delete_db(update: Update, _: CallbackContext) -> int: + logger.info( + f"Asking if the user really wants to delete the db. ({update.message.text})" + ) + + if update.message.text != "Go back": + message = "✔ Successfully deleted database file\." + db_file_path = f"{settings.ROOT_PATH}data/crypto_trading.db" + try: + copyfile(db_file_path, f"{db_file_path}.backup") + os.remove(db_file_path) + except Exception: + message = "❌ Unable to delete database file\." + else: + message = "👌 Exited without changes\.\n" "Your database was *not* deleted\." + + keyboard = [["OK"]] + reply_markup = ReplyKeyboardMarkup(keyboard, resize_keyboard=True) + update.message.reply_text( + message, reply_markup=reply_markup, parse_mode="MarkdownV2" + ) + + return MENU + + +def update_tg_bot(update: Update, _: CallbackContext) -> int: + logger.info(f"Updating BTB Manager Telegram. ({update.message.text})") + + if update.message.text != "Cancel update": + message = ( + "The bot is updating\.\n" + "Wait a few seconds then start the bot again with /start" + ) + keyboard = [["/start"]] + reply_markup = ReplyKeyboardMarkup(keyboard, resize_keyboard=True) + update.message.reply_text( + message, reply_markup=reply_markup, parse_mode="MarkdownV2" + ) + try: + subprocess.call( + "kill -9 $(ps ax | grep btb_manager_telegram | fgrep -v grep | awk '{ print $1 }') && " + "git pull && $(which python3) -m pip install -r requirements.txt && " + "$(which python3) -m btb_manager_telegram &", + shell=True, + ) + except Exception: + message = "Unable to update BTB Manager Telegram" + update.message.reply_text( + message, reply_markup=reply_markup, parse_mode="MarkdownV2" + ) + else: + message = ( + "👌 Exited without changes\.\n" "BTB Manager Telegram was *not* updated\." + ) + keyboard = [["OK 👌"]] + reply_markup = ReplyKeyboardMarkup(keyboard, resize_keyboard=True) + update.message.reply_text( + message, reply_markup=reply_markup, parse_mode="MarkdownV2" + ) + + return MENU + + +def update_btb(update: Update, _: CallbackContext) -> int: + logger.info(f"Updating Binance Trade Bot. ({update.message.text})") + + keyboard = [["OK 👌"]] + reply_markup = ReplyKeyboardMarkup(keyboard, resize_keyboard=True) + + if update.message.text != "Cancel update": + message = ( + "The bot is updating\.\n" + "Wait a few seconds, the bot will restart automatically\." + ) + update.message.reply_text( + message, reply_markup=reply_markup, parse_mode="MarkdownV2" + ) + try: + find_and_kill_process() + subprocess.call( + f"cd {settings.ROOT_PATH} && " + f"git pull && " + f"$(which python3) -m pip install -r requirements.txt && " + f"$(which python3) -m binance_trade_bot &", + shell=True, + ) + except Exception: + message = "Unable to update Binance Trade Bot" + update.message.reply_text( + message, reply_markup=reply_markup, parse_mode="MarkdownV2" + ) + else: + message = "👌 Exited without changes\.\n" "Binance Trade Bot was *not* updated\." + update.message.reply_text( + message, reply_markup=reply_markup, parse_mode="MarkdownV2" + ) + + return MENU + + +def cancel(update: Update, _: CallbackContext) -> int: + logger.info("Conversation canceled.") + + update.message.reply_text( + "Bye! I hope we can talk again some day.", + reply_markup=ReplyKeyboardRemove(), + ) + return ConversationHandler.END + + +MENU_HANDLER = MessageHandler( + Filters.regex( + "^(Begin|💵 Current value|📈 Progress|➗ Current ratios|🔍 Check bot status|⌛ Trade History|🛠 Maintenance|" + "⚙️ Configurations|▶ Start trade bot|⏹ Stop trade bot|📜 Read last log lines|❌ Delete database|" + "⚙ Edit user.cfg|👛 Edit coin list|📤 Export database|Update Telegram Bot|Update Binance Trade Bot|" + "⬅️ Back|Go back|OK|Cancel update|OK 👌)$" + ), + menu, +) + +ENTRY_POINT_HANDLER = CommandHandler( + "start", start, Filters.user(user_id=eval(settings.USER_ID)) +) + +EDIT_COIN_LIST_HANDLER = MessageHandler(Filters.regex("(.*?)"), edit_coin) + +EDIT_USER_CONFIG_HANDLER = MessageHandler(Filters.regex("(.*?)"), edit_user_config) + +DELETE_DB_HANDLER = MessageHandler(Filters.regex("^(⚠ Confirm|Go back)$"), delete_db) + +UPDATE_TG_HANDLER = MessageHandler( + Filters.regex("^(Update|Cancel update)$"), update_tg_bot +) + +UPDATE_BTB_HANDLER = MessageHandler( + Filters.regex("^(Update|Cancel update)$"), update_btb +) + +FALLBACK_HANDLER = CommandHandler("cancel", cancel) diff --git a/btb_manager_telegram/settings.py b/btb_manager_telegram/settings.py new file mode 100644 index 00000000..5b54c474 --- /dev/null +++ b/btb_manager_telegram/settings.py @@ -0,0 +1,6 @@ +ROOT_PATH = None +TOKEN = None +USER_ID = None + +TG_UPDATE_BROADCASTED_BEFORE = False +BTB_UPDATE_BROADCASTED_BEFORE = False diff --git a/btb_manager_telegram/utils.py b/btb_manager_telegram/utils.py new file mode 100644 index 00000000..1ec70d95 --- /dev/null +++ b/btb_manager_telegram/utils.py @@ -0,0 +1,178 @@ +import os +import subprocess + +import psutil +import yaml +from telegram import Bot + +from btb_manager_telegram import logger, scheduler, settings + + +def setup_root_path_constant(): + if settings.ROOT_PATH is None: + logger.info("No root_path was specified. Aborting.") + exit(-1) + else: + settings.ROOT_PATH = os.path.join(settings.ROOT_PATH, "") + + +def setup_telegram_constants(): + logger.info("Retrieving Telegram token and user_id from apprise.yml file.") + telegram_url = None + yaml_file_path = f"{settings.ROOT_PATH}config/apprise.yml" + if os.path.exists(yaml_file_path): + with open(yaml_file_path) as f: + try: + parsed_urls = yaml.load(f, Loader=yaml.FullLoader)["urls"] + except Exception: + logger.error("Unable to correctly read apprise.yml file. Make sure it is correctly set up. Aborting.") + exit(-1) + for url in parsed_urls: + if url.startswith("tgram"): + telegram_url = url.split("//")[1] + if not telegram_url: + logger.error( + "No telegram configuration was found in your apprise.yml file. Aborting." + ) + exit(-1) + else: + logger.error( + f'Unable to find apprise.yml file at "{yaml_file_path}". Aborting.' + ) + exit(-1) + try: + settings.TOKEN = telegram_url.split("/")[0] + settings.USER_ID = telegram_url.split("/")[1] + logger.info( + f"Successfully retrieved Telegram configuration. " + f"The bot will only respond to user with user_id {settings.USER_ID}" + ) + except Exception: + logger.error( + "No user_id has been set in the yaml configuration, anyone would be able to control your bot. Aborting." + ) + exit(-1) + + +def text_4096_cutter(m_list): + message = [""] + index = 0 + for mes in m_list: + if len(message[index]) + len(mes) <= 4096: + message[index] += mes + else: + message.append(mes) + index += 1 + return message + + +def find_process(): + return any( + "binance_trade_bot" in proc.name() + or "binance_trade_bot" in " ".join(proc.cmdline()) + for proc in psutil.process_iter() + ) + + +def find_and_kill_process(): + try: + for proc in psutil.process_iter(): + if "binance_trade_bot" in proc.name() or "binance_trade_bot" in " ".join( + proc.cmdline() + ): + proc.terminate() + proc.wait() + except Exception as e: + logger.info(f"ERROR: {e}") + + +def is_tg_bot_update_available(): + try: + proc = subprocess.Popen( + ["bash", "-c", "git remote update && git status -uno"], + stdout=subprocess.PIPE, + ) + output, _ = proc.communicate() + re = "Your branch is behind" in str(output) + except Exception: + re = None + return re + + +def is_btb_bot_update_available(): + try: + proc = subprocess.Popen( + [ + "bash", + "-c", + f"cd {settings.ROOT_PATH} && git remote update && git status -uno", + ], + stdout=subprocess.PIPE, + ) + output, _ = proc.communicate() + re = "Your branch is behind" in str(output) + except Exception: + re = None + return re + + +def update_checker(): + logger.info("Checking for updates.") + + if settings.TG_UPDATE_BROADCASTED_BEFORE is False: + if is_tg_bot_update_available(): + logger.info("BTB Manager Telegram update found.") + + message = ( + "⚠ An update for _BTB Manager Telegram_ is available\.\n\n" + "Please update by going to *🛠 Maintenance* and pressing the *Update Telegram Bot* button\." + ) + settings.TG_UPDATE_BROADCASTED_BEFORE = True + bot = Bot(settings.TOKEN) + bot.send_message(settings.USER_ID, message, parse_mode="MarkdownV2") + scheduler.enter( + 60 * 60 * 12, + 1, + update_reminder, + ("_*Reminder*_:\n\n" + message,), + ) + + if settings.BTB_UPDATE_BROADCASTED_BEFORE is False: + if is_btb_bot_update_available(): + logger.info("Binance Trade Bot update found.") + + message = ( + "⚠ An update for _Binance Trade Bot_ is available\.\n\n" + "Please update by going to *🛠 Maintenance* and pressing the *Update Binance Trade Bot* button\." + ) + settings.BTB_UPDATE_BROADCASTED_BEFORE = True + bot = Bot(settings.TOKEN) + bot.send_message(settings.USER_ID, message, parse_mode="MarkdownV2") + scheduler.enter( + 60 * 60 * 12, + 1, + update_reminder, + ("_*Reminder*_:\n\n" + message,), + ) + + if ( + settings.TG_UPDATE_BROADCASTED_BEFORE is False + or settings.BTB_UPDATE_BROADCASTED_BEFORE is False + ): + scheduler.enter( + 60 * 60, + 1, + update_checker, + ) + + +def update_reminder(self, message): + logger.info(f"Reminding user: {message}") + + bot = Bot(settings.TOKEN) + bot.send_message(settings.USER_ID, message, parse_mode="MarkdownV2") + scheduler.enter( + 60 * 60 * 12, + 1, + update_reminder, + ) diff --git a/docker_setup.py b/docker_setup.py new file mode 100644 index 00000000..3be9727e --- /dev/null +++ b/docker_setup.py @@ -0,0 +1,49 @@ +################################################################################ +import sys, os +import pathlib +import argparse +import subprocess +################################################################################ +PATH = pathlib.Path(__file__).parent.absolute() +os.chdir(PATH) +################################################################################ + +parser = argparse.ArgumentParser() + +parser.add_argument( + "-t", "--token", type=str, help="(optional) Telegram bot token", default=None +) +parser.add_argument( + "-u", "--user_id", type=str, help="(optional) Telegram user id", default=None +) + +args = parser.parse_args() + +tok_str = f", \"--token={args.token}\"" if args.token else "" +usr_str = f", \"--user_id={args.user_id}\"" if args.user_id else "" + +DOCKERFILE = f''' +################################################################### +# Dockerfile to build container image of: +# - BTB-manager-telegram +################################################################### + +FROM python:3 + +WORKDIR ./ + +############ Copying requirements.txt into the container ########## +COPY requirements.txt ./ + +#################### Installing dependencies ###################### +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +################## Running the main python Script ################# +CMD [ "python", "-m", "btb_manager_telegram"{tok_str}{usr_str} ] +''' + + +with open('Dockerfile', "w") as f: + f.write(DOCKERFILE) diff --git a/requirements.txt b/requirements.txt index 73d049ce..3d5ab73f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ -psutil>=5.8.0 -python-telegram-bot>=13.4.1 -pyyaml>=5.3.1 +psutil==5.8.0 +python-telegram-bot==13.4.1 +pyyaml==5.4.1 +configparser==5.0.2 diff --git a/setup_scripts/setup_by_path.py b/setup_scripts/setup_by_path.py new file mode 100644 index 00000000..3c2ed086 --- /dev/null +++ b/setup_scripts/setup_by_path.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python +# pylint: skip-file + +import shutil +import logging +import os, sys +import pathlib + +PATH = pathlib.Path(__file__).parent.parent.absolute() +os.chdir(PATH) + +if not os.path.isdir('config'): + os.mkdir('config', 0o755) + +# os.system('git submodule update --init --recursive') +if not os.isdir("binance-trade-bot"): + os.system('git clone https://github.com/edeng23/binance-trade-bot') + + +def input_copy_file(dest: str, message: str): + try: + src = input(message) + shutil.copyfile(src, dest) + except Exception as e: + logging.error(f'{e}\n') + + sys.exit(1) + + +input_copy_file("config/user.config", + "[+] Enter path to user.cfg file: ") + +input_copy_file("config/supported_coin_list", + "[+] Enter the path to supported_coin_list: ") + +input_copy_file("config/apprise.yml", + "[+] Enter the path to apprise.yml file: ") + + +print("[*] All set!")