1
0
Fork 0

Initial commit

Signed-off-by: Julien Riou <julien@riou.xyz>
This commit is contained in:
Julien Riou 2021-01-22 17:55:39 +01:00
parent b190b53d1a
commit 0f725cd02f
No known key found for this signature in database
GPG key ID: FF42D23B580C89F7
14 changed files with 383 additions and 0 deletions

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
venv
__pycache__
config.json
state.json

30
.pre-commit-config.yaml Normal file
View 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
View file

@ -0,0 +1,2 @@
[pydocstyle]
ignore = D100,D104,D400,D203,D204,D101,D213,D202

5
Dockerfile Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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()