Initial commit
Signed-off-by: Julien Riou <julien@riou.xyz>
This commit is contained in:
parent
b190b53d1a
commit
0f725cd02f
14 changed files with 383 additions and 0 deletions
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
venv
|
||||||
|
__pycache__
|
||||||
|
config.json
|
||||||
|
state.json
|
30
.pre-commit-config.yaml
Normal file
30
.pre-commit-config.yaml
Normal file
|
@ -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"']
|
2
.pydocstyle
Normal file
2
.pydocstyle
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
[pydocstyle]
|
||||||
|
ignore = D100,D104,D400,D203,D204,D101,D213,D202
|
5
Dockerfile
Normal file
5
Dockerfile
Normal file
|
@ -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
|
53
README.md
Normal file
53
README.md
Normal file
|
@ -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
|
||||||
|
```
|
14
coingecko.py
Normal file
14
coingecko.py
Normal file
|
@ -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()]
|
9
config.example.json
Normal file
9
config.example.json
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"miner": "string",
|
||||||
|
"currency": "USD",
|
||||||
|
"telegram": {
|
||||||
|
"chat_id": 123,
|
||||||
|
"auth_key": "string"
|
||||||
|
},
|
||||||
|
"state_file": "state.json"
|
||||||
|
}
|
26
config.py
Normal file
26
config.py
Normal file
|
@ -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)
|
29
config.schema.json
Normal file
29
config.schema.json
Normal file
|
@ -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"
|
||||||
|
]
|
||||||
|
}
|
119
main.py
Normal file
119
main.py
Normal file
|
@ -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()
|
11
message.md.j2
Normal file
11
message.md.j2
Normal file
|
@ -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}}
|
31
requirements.txt
Normal file
31
requirements.txt
Normal file
|
@ -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
|
12
state.py
Normal file
12
state.py
Normal file
|
@ -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)
|
38
telegram.py
Normal file
38
telegram.py
Normal file
|
@ -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()
|
Reference in a new issue