diff --git a/.gitignore b/.gitignore index e14759b..70e53ea 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,3 @@ venv __pycache__ config.json state.json -.coverage -test_state.json diff --git a/README.md b/README.md index 535066e..d5cb7f2 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ All options are optional (but the companion would do nothing). ## Usage ``` -python3 companion/main.py --help +python3 main.py --help ``` @@ -69,14 +69,12 @@ python3 companion/main.py --help Contributions are welcomed! Feel free to update the code and create a pull-request. -Be sure to lint the code and run tests before: +Be sure to lint the code before: ``` docker build -t pre-commit . docker run -it -v $(pwd):/mnt/ --rm pre-commit bash # cd /mnt/ -# pip install -r requirements.txt # pre-commit run --all-files -# pytest # exit ``` diff --git a/companion/coingecko.py b/coingecko.py similarity index 100% rename from companion/coingecko.py rename to coingecko.py diff --git a/companion/__init__.py b/companion/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/companion/config.py b/config.py similarity index 100% rename from companion/config.py rename to config.py diff --git a/companion/config.schema.json b/config.schema.json similarity index 100% rename from companion/config.schema.json rename to config.schema.json diff --git a/companion/main.py b/main.py similarity index 98% rename from companion/main.py rename to main.py index 11bf80f..8ab3ec3 100644 --- a/companion/main.py +++ b/main.py @@ -79,7 +79,7 @@ def main(): last_balance, last_transaction = handler.watch_miner(address=config['miner'], last_balance=pool_state.get('balance'), last_transaction=pool_state.get('payment')) - if last_balance is not None: + if last_balance: logger.debug(f'saving {pool} miner balance to state file') state.write(pool_name=pool, miner_balance=last_balance) if last_transaction: diff --git a/companion/pools/__init__.py b/pools/__init__.py similarity index 54% rename from companion/pools/__init__.py rename to pools/__init__.py index ddff2ee..6523eda 100644 --- a/companion/pools/__init__.py +++ b/pools/__init__.py @@ -30,20 +30,22 @@ class Handler: return miner.raw_balance def _watch_miner_payments(self, miner, last_transaction=None): - logger.debug('watching miner payments') - if miner.last_transaction and (not last_transaction or miner.last_transaction.txid != last_transaction): - # send notifications for last payment only - logger.info(f'new payment {miner.last_transaction.txid}') - if self.notifier: - logger.debug('sending payment notification') - arguments = {'pool': self.pool_name, 'address': miner.address, 'txid': miner.last_transaction.txid, - 'amount': miner.last_transaction.amount, 'amount_fiat': miner.last_transaction.amount_fiat, - 'time': miner.last_transaction.time, 'duration': miner.last_transaction.duration} - try: - self.notifier.notify_payment(**arguments) - logger.info('payment notification sent') - except Exception as err: - logger.error('failed to send notification') - logger.exception(err) + if miner.last_transaction and miner.last_transaction.txid != last_transaction: + logger.debug('watching miner payments') + # send notifications for recent payements only + for transaction in miner.transactions[MAX_NOTIFICATIONS_COUNT:]: + if not last_transaction or transaction.txid > last_transaction: + logger.info(f'new payment {transaction.txid}') + if self.notifier: + logger.debug('sending payment notification') + arguments = {'pool': self.pool_name, 'address': miner.address, 'txid': transaction.txid, + 'amount': transaction.amount, 'amount_fiat': transaction.amount_fiat, + 'time': transaction.time, 'duration': transaction.duration} + try: + self.notifier.notify_payment(**arguments) + logger.info('payment notification sent') + except Exception as err: + logger.error('failed to send notification') + logger.exception(err) if miner.last_transaction and miner.last_transaction.txid: return miner.last_transaction.txid diff --git a/companion/pools/ethermine.py b/pools/ethermine.py similarity index 100% rename from companion/pools/ethermine.py rename to pools/ethermine.py diff --git a/companion/pools/flexpool.py b/pools/flexpool.py similarity index 78% rename from companion/pools/flexpool.py rename to pools/flexpool.py index f39420a..9f2f50c 100644 --- a/companion/pools/flexpool.py +++ b/pools/flexpool.py @@ -40,13 +40,12 @@ class Miner: miner = flexpoolapi.miner(address) self.raw_balance = miner.balance() self.balance = convert_weis(self.raw_balance) - self.balance_fiat = None if exchange_rate and currency: self.balance_fiat = convert_fiat(amount=self.raw_balance, exchange_rate=exchange_rate, currency=currency) payout_threshold = self.get_payout_threshold(miner) self.balance_percentage = self.format_balance_percentage(payout_threshold=payout_threshold, balance=self.raw_balance) - self.transactions = self.get_payements(miner, exchange_rate=exchange_rate, currency=currency) + self.transactions = self.get_payements(miner) @property def url(self): @@ -131,8 +130,7 @@ class FlexpoolHandler(Handler): blocks = self.get_blocks(exchange_rate=self.exchange_rate, currency=self.currency) if blocks: # don't spam block notification at initialization - notification_slice = MAX_NOTIFICATIONS_COUNT if len(blocks) > MAX_NOTIFICATIONS_COUNT else 0 - for block in blocks[notification_slice:]: + for block in blocks[MAX_NOTIFICATIONS_COUNT:]: if not last_block or last_block < block.number: logger.info(f'new block {block.number}') if self.notifier: @@ -146,45 +144,35 @@ class FlexpoolHandler(Handler): except Exception as err: logger.error('failed to send notification') logger.exception(err) - last_remote_block = block + last_remote_block = block if last_remote_block and last_remote_block.number: return last_remote_block.number @staticmethod def get_blocks(exchange_rate=None, currency=None): - try: - remote_blocks = flexpoolapi.pool.last_blocks(count=MAX_BLOCKS_COUNT) - # convert to blocks - blocks = [] - if remote_blocks: - for remote_block in remote_blocks: - block = Block(number=remote_block.number, hash=remote_block.hash, time=remote_block.time, - round_time=remote_block.round_time, reward=remote_block.total_rewards, - luck=remote_block.luck, exchange_rate=exchange_rate, currency=currency) - blocks.append(block) - # sort by block number - return sorted(blocks) - except flexpoolapi.exceptions.APIError as err: - logger.warning('failed to get blocks from Flexpool API') - logger.debug(err) + remote_blocks = flexpoolapi.pool.last_blocks(count=MAX_BLOCKS_COUNT) + # convert to blocks + blocks = [] + for remote_block in remote_blocks: + block = Block(number=remote_block.number, hash=remote_block.hash, time=remote_block.time, + round_time=remote_block.round_time, reward=remote_block.total_rewards, luck=remote_block.luck, + exchange_rate=exchange_rate, currency=currency) + blocks.append(block) + # sort by block number + return sorted(blocks) def watch_miner(self, address, last_balance=None, last_transaction=None): logger.debug(f'watching miner {address}') try: miner = Miner(address=address, exchange_rate=self.exchange_rate, currency=self.currency) - logger.debug(miner) - - last_balance = self._watch_miner_balance(miner=miner, last_balance=last_balance) - last_transaction = self._watch_miner_payments(miner=miner, last_transaction=last_transaction) - - return last_balance, last_transaction - except flexpoolapi.exceptions.InvalidMinerAddress as err: - logger.error(f'miner address {address} is invalid') - logger.debug(err) - except flexpoolapi.exceptions.MinerDoesNotExist as err: + except Exception as err: logger.error(f'miner {address} not found') - logger.debug(err) - except flexpoolapi.exceptions.APIError as err: - logger.warning('failed to get miner from Flexpool API') - logger.debug(err) - return None, None + logger.exception(err) + return + + logger.debug(miner) + + last_balance = self._watch_miner_balance(miner=miner, last_balance=last_balance) + last_transaction = self._watch_miner_payments(miner=miner, last_transaction=last_transaction) + + return last_balance, last_transaction diff --git a/pytest.ini b/pytest.ini deleted file mode 100644 index f5c75eb..0000000 --- a/pytest.ini +++ /dev/null @@ -1,2 +0,0 @@ -[pytest] -addopts = --cov=companion tests/ diff --git a/requirements.txt b/requirements.txt index 165da09..1ad3230 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,3 @@ -r requirements/base.txt -r requirements/ethermine.txt -r requirements/flexpool.txt --r requirements/tests.txt diff --git a/requirements/tests.txt b/requirements/tests.txt deleted file mode 100644 index 89b1fa6..0000000 --- a/requirements/tests.txt +++ /dev/null @@ -1,3 +0,0 @@ -pytest==6.2.2 -pytest-cov==2.11.1 -pytest-mock==3.5.1 diff --git a/companion/state.py b/state.py similarity index 92% rename from companion/state.py rename to state.py index 491d033..e1a9537 100644 --- a/companion/state.py +++ b/state.py @@ -20,9 +20,9 @@ class State: content = self.read() if pool_name not in content: content[pool_name] = {} - if block_number is not None: + if block_number: content[pool_name]['block'] = block_number - if miner_balance is not None: + if miner_balance: content[pool_name]['balance'] = miner_balance if miner_payment: content[pool_name]['payment'] = miner_payment diff --git a/companion/telegram.py b/telegram.py similarity index 98% rename from companion/telegram.py rename to telegram.py index ce6c5f2..a213a8d 100644 --- a/companion/telegram.py +++ b/telegram.py @@ -18,7 +18,7 @@ class TelegramNotifier: @staticmethod def _markdown_escape(text): text = str(text) - for special_char in ['\\', '`', '*', '_', '{', '}', '[', ']', '(', ')', '#', '+', '-', '.', '!', '=']: + for special_char in ['\\', '`', '*', '_', '{', '}', '[', ']', '(', ')', '#', '+', '-', '.', '!']: text = text.replace(special_char, fr'\{special_char}') return text diff --git a/companion/templates/balance.md.j2 b/templates/balance.md.j2 similarity index 100% rename from companion/templates/balance.md.j2 rename to templates/balance.md.j2 diff --git a/companion/templates/block.md.j2 b/templates/block.md.j2 similarity index 100% rename from companion/templates/block.md.j2 rename to templates/block.md.j2 diff --git a/companion/templates/payment.md.j2 b/templates/payment.md.j2 similarity index 100% rename from companion/templates/payment.md.j2 rename to templates/payment.md.j2 diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index f1487c8..0000000 --- a/tests/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -import os -import sys - -sys.path.append(os.path.join(os.path.dirname(__file__), os.pardir, 'companion')) diff --git a/tests/test_flexpool.py b/tests/test_flexpool.py deleted file mode 100644 index 7a1324f..0000000 --- a/tests/test_flexpool.py +++ /dev/null @@ -1,142 +0,0 @@ -from datetime import datetime, timedelta - -import pytest -from companion.pools.flexpool import FlexpoolHandler, Transaction -from flexpoolapi.shared import Block as BlockApi - - -class TestFlexpoolHandler: - def test_init(self): - handler = FlexpoolHandler() - assert handler.pool_name == 'flexpool' - - @pytest.mark.parametrize( - 'old_balance,new_balance,should_notify', - [ - pytest.param(1, 2, True, id='new_balance_with_notification'), - pytest.param(1, 0, True, id='new_balance_after_payment_with_notification'), - pytest.param(None, 1, True, id='very_new_balance_with_notification'), - pytest.param(1, 1, False, id='same_balance_without_notification'), - ] - ) - def test_balance(self, mocker, old_balance, new_balance, should_notify): - notifier = mocker.Mock() - notifier.notify_balance = mocker.Mock() - handler = FlexpoolHandler(notifier=notifier) - miner = mocker.patch('flexpoolapi.miner') - miner().balance.return_value = new_balance - mocker.patch('companion.pools.flexpool.FlexpoolHandler._watch_miner_payments') - mocker.patch('companion.pools.flexpool.Miner.get_payements') - last_balance, last_transaction = handler.watch_miner(address='addr', last_balance=old_balance) - assert last_balance == new_balance - if should_notify: - notifier.notify_balance.assert_called_once() - else: - notifier.notify_balance.assert_not_called() - - def test_balance_with_api_failure(self, mocker): - """An API failure should not send a balance notification""" - notifier = mocker.Mock() - notifier.notify_balance = mocker.Mock() - handler = FlexpoolHandler(notifier=notifier) - request_get = mocker.patch('requests.get') - request_get.return_value.status_code = 503 - mocker.patch('companion.pools.flexpool.FlexpoolHandler._watch_miner_payments') - mocker.patch('companion.pools.flexpool.Miner.get_payements') - last_balance, last_transaction = handler.watch_miner(address='0000000000000000000000000000000000000001', - last_balance=1) - assert last_balance is None - notifier.notify_balance.assert_not_called() - - @staticmethod - def _create_transactions(names): - if names: - return [Transaction(txid=n, amount=1, time=datetime.now(), duration=timedelta(minutes=1)) for n in names] - - @pytest.mark.parametrize( - 'old_transaction,new_transactions,should_notify', - [ - pytest.param('trx1', ['trx1', 'trx2'], True, id='new_payment_with_notification'), - pytest.param(None, ['trx1'], True, id='very_new_payment_with_notification'), - pytest.param('trx1', ['trx1'], False, id='same_payment_without_notification'), - pytest.param(None, None, False, id='zero_payment_without_notification'), - ] - ) - def test_payments(self, mocker, old_transaction, new_transactions, should_notify): - notifier = mocker.Mock() - notifier.notify_payment = mocker.Mock() - handler = FlexpoolHandler(notifier=notifier) - mocker.patch('flexpoolapi.miner') - mocker.patch('companion.pools.flexpool.FlexpoolHandler._watch_miner_balance') - get_payements = mocker.patch('companion.pools.flexpool.Miner.get_payements') - get_payements.return_value = self._create_transactions(new_transactions) - last_balance, last_transaction = handler.watch_miner(address='addr', last_transaction=old_transaction) - if new_transactions: - assert last_transaction == new_transactions[-1] - else: - assert last_transaction is None - if should_notify: - notifier.notify_payment.assert_called_once() - else: - notifier.notify_payment.assert_not_called() - - def test_payment_with_api_failure(self, mocker): - """An API failure should not send a payment notification""" - notifier = mocker.Mock() - notifier.notify_payment = mocker.Mock() - handler = FlexpoolHandler(notifier=notifier) - request_get = mocker.patch('requests.get') - request_get.return_value.status_code = 503 - mocker.patch('companion.pools.flexpool.FlexpoolHandler._watch_miner_balance') - last_balance, last_transaction = handler.watch_miner(address='0000000000000000000000000000000000000001', - last_transaction=1) - assert last_transaction is None - notifier.notify_payment.assert_not_called() - - @staticmethod - def _create_blocks(numbers): - if numbers: - blocks = [] - for number in numbers: - blocks.append(BlockApi(number=number, blockhash='h', block_type='bt', miner='m', difficulty=1, - timestamp=1, is_confirmed=True, round_time=1, luck=1.0, server_name='s', - block_reward=1, block_fees=1, uncle_inclusion_rewards=1, total_rewards=1)) - return blocks - - @pytest.mark.parametrize( - 'last_block,remote_blocks,should_notify', - [ - pytest.param(1, [1, 2], True, id='new_block_with_notification'), - pytest.param(None, [1], True, id='very_new_block_with_notification'), - pytest.param(1, [1], False, id='same_block_without_notification'), - pytest.param(9, range(1, 11), True, id='new_block_with_count_over_max_notification'), - pytest.param(10, range(1, 11), False, id='same_block_with_count_over_max_notification'), - pytest.param(None, None, False, id='zero_block_without_notification'), - ] - ) - def test_block(self, mocker, last_block, remote_blocks, should_notify): - notifier = mocker.Mock() - notifier.notify_block = mocker.Mock() - handler = FlexpoolHandler(notifier=notifier) - last_blocks = mocker.patch('flexpoolapi.pool.last_blocks') - last_blocks.return_value = self._create_blocks(remote_blocks) - block = handler.watch_blocks(last_block=last_block) - if remote_blocks: - assert block == remote_blocks[-1] - else: - assert block is None - if should_notify: - notifier.notify_block.assert_called_once() - else: - notifier.notify_block.assert_not_called() - - def test_block_with_api_failure(self, mocker): - """An API failure should not send a block notification""" - notifier = mocker.Mock() - notifier.notify_block = mocker.Mock() - handler = FlexpoolHandler(notifier=notifier) - request_get = mocker.patch('requests.get') - request_get.return_value.status_code = 503 - block = handler.watch_blocks(last_block=1) - assert block is None - notifier.notify_block.assert_not_called() diff --git a/tests/test_state.py b/tests/test_state.py deleted file mode 100644 index f623d5a..0000000 --- a/tests/test_state.py +++ /dev/null @@ -1,100 +0,0 @@ -import json -import os - -import pytest -from companion.state import State - - -class TestState: - FILENAME = 'test_state.json' - POOL_NAME = 'testpool' - CONTENT = { - 'testpool': { - 'block': 1234, - 'balance': 1234, - 'payment': '0x0000000' - } - } - - @pytest.fixture(scope='function') - def state(self): - return State(self.FILENAME) - - @pytest.fixture(scope='function') - def create_state(self): - with open(self.FILENAME, 'w') as fd: - json.dump(self.CONTENT, fd, indent=2) - yield - if os.path.isfile(self.FILENAME): - os.unlink(self.FILENAME) - - @pytest.fixture(scope='function') - def remove_state(self): - yield - if os.path.isfile(self.FILENAME): - os.unlink(self.FILENAME) - - def test_init(self, state, remove_state): - assert os.path.isfile(self.FILENAME) - with open(self.FILENAME, 'r') as fd: - assert json.load(fd) == {} - - def test_read(self, state, create_state): - content = state.read() - for pool in self.CONTENT: - assert pool in content - for key in self.CONTENT[pool]: - assert key in content[pool] and content[pool][key] == self.CONTENT[pool][key] - - def test_write(self, state): - state.write(pool_name=self.POOL_NAME) - content = state.read() - assert content[self.POOL_NAME] == {} - - def test_write_block(self, create_state, state): - state.write(pool_name=self.POOL_NAME, block_number=5678) - content = state.read() - assert content[self.POOL_NAME]['block'] == 5678 - - def test_write_empty_block(self, create_state, state): - state.write(pool_name=self.POOL_NAME, block_number=None) - content = state.read() - assert content[self.POOL_NAME]['block'] == self.CONTENT[self.POOL_NAME]['block'] # not changed - - def test_write_zero_block(self, create_state, state): - state.write(pool_name=self.POOL_NAME, block_number=0) - content = state.read() - assert content[self.POOL_NAME]['block'] == 0 - - def test_write_balance(self, create_state, state): - state.write(pool_name=self.POOL_NAME, miner_balance=5678) - content = state.read() - assert content[self.POOL_NAME]['balance'] == 5678 - - def test_write_empty_balance(self, create_state, state): - state.write(pool_name=self.POOL_NAME, miner_balance=None) - content = state.read() - assert content[self.POOL_NAME]['balance'] == self.CONTENT[self.POOL_NAME]['balance'] # not changed - - def test_write_zero_balance(self, create_state, state): - state.write(pool_name=self.POOL_NAME, miner_balance=0) - content = state.read() - assert content[self.POOL_NAME]['balance'] == 0 - - def test_write_payment(self, create_state, state): - state.write(pool_name=self.POOL_NAME, miner_payment='0x1111111') - content = state.read() - assert content[self.POOL_NAME]['payment'] == '0x1111111' - - def test_write_empty_payment(self, create_state, state): - state.write(pool_name=self.POOL_NAME, miner_payment=None) - content = state.read() - assert content[self.POOL_NAME]['payment'] == self.CONTENT[self.POOL_NAME]['payment'] # not changed - - def test_get(self, create_state): - state = State(filename=self.FILENAME) - assert state.get(self.POOL_NAME) == self.CONTENT[self.POOL_NAME] - - def test_get_missing_key(self, create_state): - state = State(filename=self.FILENAME) - assert state.get('UNKNOWN_POOL') == {} diff --git a/companion/utils.py b/utils.py similarity index 100% rename from companion/utils.py rename to utils.py