From 0f725cd02fcdf3bee4f9da2a17a9daea735a79c7 Mon Sep 17 00:00:00 2001 From: Julien Riou Date: Fri, 22 Jan 2021 17:55:39 +0100 Subject: [PATCH] Initial commit Signed-off-by: Julien Riou --- .gitignore | 4 ++ .pre-commit-config.yaml | 30 ++++++++++ .pydocstyle | 2 + Dockerfile | 5 ++ README.md | 53 ++++++++++++++++++ coingecko.py | 14 +++++ config.example.json | 9 +++ config.py | 26 +++++++++ config.schema.json | 29 ++++++++++ main.py | 119 ++++++++++++++++++++++++++++++++++++++++ message.md.j2 | 11 ++++ requirements.txt | 31 +++++++++++ state.py | 12 ++++ telegram.py | 38 +++++++++++++ 14 files changed, 383 insertions(+) create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 .pydocstyle create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 coingecko.py create mode 100644 config.example.json create mode 100644 config.py create mode 100644 config.schema.json create mode 100644 main.py create mode 100644 message.md.j2 create mode 100644 requirements.txt create mode 100644 state.py create mode 100644 telegram.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..70e53ea --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +venv +__pycache__ +config.json +state.json diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..a14542e --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,30 @@ +--- +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: master + hooks: + - id: check-executables-have-shebangs + - id: check-merge-conflict + - id: double-quote-string-fixer + - id: end-of-file-fixer + - id: fix-encoding-pragma + args: ['--remove'] + - id: requirements-txt-fixer + - id: trailing-whitespace + - id: check-json + - id: check-yaml + - repo: https://gitlab.com/pycqa/flake8 + rev: master + hooks: + - id: flake8 + args: ['--max-line-length=120'] + - repo: https://github.com/FalconSocial/pre-commit-python-sorter + rev: master + hooks: + - id: python-import-sorter + args: ['--silent-overwrite'] + - repo: https://github.com/chewse/pre-commit-mirrors-pydocstyle + rev: master + hooks: + - id: pydocstyle + args: ['--config=.pydocstyle', '--match="(?!test_).*\.py"'] diff --git a/.pydocstyle b/.pydocstyle new file mode 100644 index 0000000..aef2483 --- /dev/null +++ b/.pydocstyle @@ -0,0 +1,2 @@ +[pydocstyle] +ignore = D100,D104,D400,D203,D204,D101,D213,D202 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3629d9a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,5 @@ +FROM python:3.8-slim +RUN apt-get update \ + && apt-get install -y git \ + && apt-get autoclean \ + && pip install pre-commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..22eb01b --- /dev/null +++ b/README.md @@ -0,0 +1,53 @@ +# flexpool-activity + +[Flexpool.io](https://flexpool.io) is a next-generation [Ethereum](https://ethereum.org/en/) mining pool known for their +[#STOPEIP1559](https://stopeip1559.org/) move. `flexpool-activity` is able to listen and notify when a new **block** is +mined by the pool and display the up-to-date **miner balance** and convert it to **fiat**. + +## Installation + +``` +sudo apt install python3-virtualenv +virtualenv venv +source venv/bin/activate +pip install -r requirements.txt +``` + +## Telegram bot + +This [tutorial](https://takersplace.de/2019/12/19/telegram-notifications-with-nagios/) explains how to create a Telegram +bot. You'll need the `chat_id` and `auth_key` for the next section. + +## Configuration + +Configuration file use the JSON format with the following keys: +* `miner`: wallet address of the miner +* `currency`: symbol of the currency to convert (default: USD) +* `telegram`: send notifications with Telegram (optional) + * `auth_key`: Telegram authentication key for the bot API + * `chat_id`: Telegram chat room id (where to send the message) +* `state_file`: persist data between runs into this file (default: `state.json`) + +See [configuration example](config.example.json). + + +## Usage + +``` +python3 main --help +``` + + +## Contribute + +Contributions are welcomed! Feel free to update the code and create a pull-request. + +Be sure to lint the code before: + +``` +docker build -t pre-commit . +docker run -it -v $(pwd):/mnt/ --rm pre-commit bash +# cd /mnt/ +# pre-commit run --all-files +# exit +``` diff --git a/coingecko.py b/coingecko.py new file mode 100644 index 0000000..2d45cc7 --- /dev/null +++ b/coingecko.py @@ -0,0 +1,14 @@ +import logging + +import requests + +logger = logging.getLogger(__name__) + + +def get_rate(ids, vs_currencies): + logger.debug(f'getting {ids} price in {vs_currencies} on coingecko') + url = f'https://api.coingecko.com/api/v3/simple/price?ids={ids}&vs_currencies={vs_currencies}' + r = requests.get(url) + r.raise_for_status() + logger.debug(r.json()) + return r.json()[ids.lower()][vs_currencies.lower()] diff --git a/config.example.json b/config.example.json new file mode 100644 index 0000000..dcbdf69 --- /dev/null +++ b/config.example.json @@ -0,0 +1,9 @@ +{ + "miner": "string", + "currency": "USD", + "telegram": { + "chat_id": 123, + "auth_key": "string" + }, + "state_file": "state.json" +} diff --git a/config.py b/config.py new file mode 100644 index 0000000..1c8a8cb --- /dev/null +++ b/config.py @@ -0,0 +1,26 @@ +import json +import os + +from jsonschema import validate + +absolute_path = os.path.split(os.path.abspath(__file__))[0] + + +class InvalidConfigException(Exception): + pass + + +def read_config(filename=None): + if filename and os.path.isfile(filename): + with open(filename, 'r') as fd: + return json.load(fd) + else: + return {} + + +def validate_config(config): + if config is None: + raise InvalidConfigException('config is not a dict') + with open(os.path.join(absolute_path, 'config.schema.json'), 'r') as fd: + schema = json.loads(fd.read()) + validate(instance=config, schema=schema) diff --git a/config.schema.json b/config.schema.json new file mode 100644 index 0000000..2a37ead --- /dev/null +++ b/config.schema.json @@ -0,0 +1,29 @@ +{ + "type": "object", + "properties": { + "telegram": { + "type": "object", + "properties": { + "chat_id": { + "type": "number" + }, + "auth_key": { + "type": "string" + } + }, + "required": [ + "chat_id", + "auth_key" + ] + }, + "miner": { + "type": "string" + }, + "state_file": { + "type": "string" + } + }, + "required": [ + "miner" + ] +} diff --git a/main.py b/main.py new file mode 100644 index 0000000..3ebd28b --- /dev/null +++ b/main.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +import argparse +import logging +import os + +import flexpoolapi +import telegram +from coingecko import get_rate +from config import read_config, validate_config +from flexpoolapi.utils import format_weis +from humanfriendly import format_timespan +from requests.exceptions import HTTPError +from state import read_state, write_state + +logger = logging.getLogger(__name__) + + +def parse_arguments(): + parser = argparse.ArgumentParser() + parser.add_argument('-v', '--verbose', dest='loglevel', action='store_const', const=logging.INFO, + help='print more output') + parser.add_argument('-d', '--debug', dest='loglevel', action='store_const', const=logging.DEBUG, + default=logging.WARNING, help='print even more output') + parser.add_argument('-o', '--logfile', help='logging file location') + parser.add_argument('-N', '--disable-notifications', dest='disable_notifications', action='store_true', + help='do not send notifications') + parser.add_argument('-c', '--config', help='configuration file name', default='config.json') + args = parser.parse_args() + return args + + +def setup_logging(args): + log_format = '%(asctime)s %(levelname)s: %(message)s' if args.logfile else '%(levelname)s: %(message)s' + logging.basicConfig(format=log_format, level=args.loglevel, filename=args.logfile) + + +def convert_weis(weis, prec=5): + return round(weis / 10**18, prec) + + +def main(): + args = parse_arguments() + setup_logging(args) + + config = read_config(args.config) + validate_config(config) + state_file = config.get('state_file', 'state.json') + currency = config.get('currency', 'USD') + + logger.debug('fetching last mined block') + block = flexpoolapi.pool.last_blocks(count=1)[0] + + block_number = block.number + block_time = block.time + block_reward = format_weis(block.total_rewards) + block_round_time = format_timespan(block.round_time) + block_luck = f'{int(block.luck*100)}%' + + if not block: + logger.info('no block found') + return + + if not os.path.isfile(state_file): + logger.debug('creating state file') + write_state(filename=state_file, block_number=block.number) + return + + logger.debug('reading state file') + state = read_state(filename=state_file) + + if block.number != state['block']: + logger.info(f'block {block.number} mined') + + logger.debug(f'block time: {block_time}') + logger.debug(f'block reward: {block_reward}') + logger.debug(f'block round time: {block_round_time}') + logger.debug(f'block luck: {block_luck}') + + logger.debug('fetching miner details') + + miner = flexpoolapi.miner(config['miner']) + miner_balance = miner.balance() + payout_threshold = miner.details().min_payout_threshold + balance_percentage = f'{round(miner_balance*100/payout_threshold, 2)}%' + + logger.debug(f'miner unpaid balance: {format_weis(miner_balance)} ({balance_percentage})') + + logger.debug('fetching current rate') + try: + rate = get_rate(ids='ethereum', vs_currencies=currency) + except HTTPError as err: + logger.error(f'failed to get ETH/{currency} rate') + logger.debug(str(err)) + return + + balance_converted = round(convert_weis(miner_balance)*rate, 2) + logger.debug(f'miner unpaid balance converted: {balance_converted} {currency}') + + if not args.disable_notifications and config.get('telegram'): + logger.debug('sending telegram notification') + variables = {'block_number': block_number, 'block_time': block_time, 'block_reward': block_reward, + 'block_round_time': block_round_time, 'block_luck': block_luck, + 'miner_address': config['miner'], 'miner_balance': format_weis(miner_balance), + 'miner_balance_converted': balance_converted, 'miner_balance_percentage': balance_percentage, + 'currency': currency} + payload = telegram.generate_payload(chat_id=config['telegram']['chat_id'], message_variables=variables) + try: + telegram.send_message(auth_key=config['telegram']['auth_key'], payload=payload) + logger.info('notification sent to telegram') + except HTTPError as err: + logger.error('failed to send notification to telegram') + logger.debug(str(err)) + + logger.debug('writing block to state file') + write_state(filename=state_file, block_number=block.number) + + +if __name__ == '__main__': + main() diff --git a/message.md.j2 b/message.md.j2 new file mode 100644 index 0000000..62ef328 --- /dev/null +++ b/message.md.j2 @@ -0,0 +1,11 @@ +*⛏️ Block {{block_number}} mined by Flexpool* + +*Date/Time*: {{block_time}} +*Reward*: {{block_reward}} +*Round time*: {{block_round_time}} +*Luck*: {{block_luck}} + +💰 {{miner_address}} + +*Unpaid balance*: {{miner_balance}} \({{miner_balance_converted}} {{currency}}\) +*Unpaid percentage*: {{miner_balance_percentage}} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..00b5825 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,31 @@ +appdirs==1.4.3 +attrs==20.3.0 +CacheControl==0.12.6 +certifi==2019.11.28 +chardet==3.0.4 +colorama==0.4.3 +contextlib2==0.6.0 +distlib==0.3.0 +distro==1.4.0 +flexpoolapi==1.2.7.post1 +html5lib==1.0.1 +humanfriendly==9.1 +idna==2.8 +ipaddr==2.2.0 +Jinja2==2.11.2 +jsonschema==3.2.0 +lockfile==0.12.2 +MarkupSafe==1.1.1 +msgpack==0.6.2 +packaging==20.3 +pep517==0.8.2 +progress==1.5 +pyparsing==2.4.6 +pyrsistent==0.17.3 +pytoml==0.1.21 +requests==2.22.0 +retrying==1.3.3 +si-prefix==1.2.2 +six==1.14.0 +urllib3==1.25.8 +webencodings==0.5.1 diff --git a/state.py b/state.py new file mode 100644 index 0000000..8c8b400 --- /dev/null +++ b/state.py @@ -0,0 +1,12 @@ +import json + + +def write_state(filename, block_number): + data = {'block': block_number} + with open(filename, 'w') as fd: + json.dump(data, fd) + + +def read_state(filename): + with open(filename, 'r') as fd: + return json.load(fd) diff --git a/telegram.py b/telegram.py new file mode 100644 index 0000000..51a0125 --- /dev/null +++ b/telegram.py @@ -0,0 +1,38 @@ +import logging +import os + +import requests +from jinja2 import Environment, FileSystemLoader + +logger = logging.getLogger(__name__) +absolute_path = os.path.split(os.path.abspath(__file__))[0] + + +def markdown_escape(text): + text = str(text) + for special_char in ['\\', '`', '*', '_', '{', '}', '[', ']', '(', ')', '#', '+', '-', '.', '!']: + text = text.replace(special_char, fr'\{special_char}') + return text + + +def generate_payload(chat_id, message_variables, template_file_name=None): + payload = {'chat_id': chat_id, 'parse_mode': 'MarkdownV2'} + if not template_file_name: + template_file_name = os.path.join(absolute_path, 'message.md.j2') + template_path = os.path.dirname(os.path.abspath(template_file_name)) + template_name = os.path.basename(os.path.abspath(template_file_name)) + loader = FileSystemLoader(template_path) + env = Environment(loader=loader) + template = env.get_template(template_name) + template_variables = {} + for key, value in message_variables.items(): + template_variables[key] = markdown_escape(value) + text = template.render(**template_variables) + payload['text'] = text + return payload + + +def send_message(auth_key, payload): + logger.debug(payload) + r = requests.post(f'https://api.telegram.org/bot{auth_key}/sendMessage', json=payload) + r.raise_for_status()